Browse Source

feat: Implement URL-based localization support for Blazor components and enhance menu item URL handling

pull/25174/head
maliming 18 hours ago
parent
commit
0880b23d7c
No known key found for this signature in database GPG Key ID: A646B9CB645ECEA4
  1. 44
      docs/en/Community-Articles/2026-03-29-Url-Based-Localization/POST.md
  2. 35
      docs/en/framework/fundamentals/url-based-localization.md
  3. 75
      framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/AbpWasmCultureMenuItemUrlProvider.cs
  4. 2
      framework/src/Volo.Abp.AspNetCore.Mvc.Contracts/Volo/Abp/AspNetCore/Mvc/ApplicationConfigurations/ApplicationLocalizationConfigurationDto.cs
  5. 1
      framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AbpAspNetCoreMvcModule.cs
  6. 5
      framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApplicationConfigurations/AbpApplicationConfigurationAppService.cs
  7. 23
      framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpAspNetCoreMvcQueryStringCultureReplacement.cs
  8. 25
      framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureMenuItemUrlProvider.cs
  9. 58
      framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureMenuItemUrlProvider_Tests.cs
  10. 2
      modules/account/src/Volo.Abp.Account.Blazor/AbpAccountBlazorUserMenuContributor.cs
  11. 2
      modules/basic-theme/src/Volo.Abp.AspNetCore.Components.Server.BasicTheme/Themes/Basic/LoginDisplay.razor
  12. 11
      modules/basic-theme/src/Volo.Abp.AspNetCore.Components.Server.BasicTheme/Themes/Basic/LoginDisplay.razor.cs
  13. 27
      modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/Themes/Basic/LanguageSwitch.razor
  14. 2
      modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/Themes/Basic/LoginDisplay.razor
  15. 31
      modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/Themes/Basic/LoginDisplay.razor.cs

44
docs/en/Community-Articles/2026-03-29-Url-Based-Localization/POST.md

@ -89,12 +89,12 @@ Blazor WebAssembly (WebApp) is similar. The server renders the first page via SS
### What works automatically
| Feature | How it works |
|---|---|
| **Culture detection** | `RouteDataRequestCultureProvider` reads `{culture}` from the URL on the initial HTTP request (SSR). |
| **Cookie persistence** | The middleware saves the detected culture to the `.AspNetCore.Culture` cookie, which persists across the WebSocket connection. |
| **Menu URLs** | `AbpCultureMenuItemUrlProvider` prepends the culture prefix. In Blazor interactive circuits (where `HttpContext` is null — no active HTTP request), it falls back to `CultureInfo.CurrentCulture`. |
| **Language switching** | The built-in `LanguageSwitch` component navigates to `/Abp/Languages/Switch` with `forceLoad: true`, triggering a full HTTP reload. The culture segment in the return URL is automatically replaced. |
| Feature | Blazor Server | Blazor WebApp (WASM) |
|---|---|---|
| **Culture detection** | `RouteDataRequestCultureProvider` reads `{culture}` on the initial HTTP request (SSR). | Same — SSR on first load. |
| **Cookie persistence** | Middleware saves the culture to `.AspNetCore.Culture` cookie, which persists across the WebSocket connection. | Cookie is set during SSR. WASM reads the `UseRouteBasedCulture` flag from `/api/abp/application-configuration`. |
| **Menu URLs** | `AbpCultureMenuItemUrlProvider` prepends the culture prefix. Falls back to `CultureInfo.CurrentCulture` in interactive circuits where `HttpContext` is null. | `AbpWasmCultureMenuItemUrlProvider` reads the flag and language list from the cached application configuration. |
| **Language switching** | `LanguageSwitch` navigates to `/Abp/Languages/Switch` with `forceLoad: true`, triggering a full HTTP reload. | `LanguageSwitch` replaces the culture segment in the URL client-side and navigates with `forceLoad: true`. |
### Important: Blazor component route limitation
@ -128,40 +128,56 @@ You must **manually** add the `{culture}` route to each of your own Blazor pages
### Language switching uses forceLoad
Language switching in Blazor triggers a **full page reload** rather than a SPA-style client navigation. This is by design — switching languages requires the server-side middleware to set the new culture, update the cookie, and re-render all localized text (menus, labels, content). This is the same behavior as ABP's built-in `LanguageSwitch` component:
Language switching in Blazor triggers a **full page reload** rather than a SPA-style client navigation. This is by design — switching languages requires re-rendering all localized text (menus, labels, content) with the new culture.
**Blazor Server** navigates to `/Abp/Languages/Switch` on the server, which rewrites the culture segment and redirects back:
```csharp
// ABP BasicTheme LanguageSwitch.razor
NavigationManager.NavigateTo(
$"Abp/Languages/Switch?culture={language.CultureName}&uiCulture={language.UiCultureName}&returnUrl={relativeUrl}",
forceLoad: true
);
```
**Blazor WebApp (WASM)** replaces the culture segment directly in the URL client-side and navigates with `forceLoad: true`:
```csharp
var relativePath = NavigationManager.ToBaseRelativePath(NavigationManager.Uri);
// Replace the first path segment if it matches a known culture
var newRelativePath = language.CultureName + remainingPath;
NavigationManager.NavigateTo(
NavigationManager.ToAbsoluteUri(newRelativePath).ToString(),
forceLoad: true);
```
Normal page-to-page navigation (within the same language) remains client-side and fast. Only language switching triggers a reload.
### Example module configuration
```csharp
// Server project
Configure<AbpRequestLocalizationOptions>(options =>
{
options.UseRouteBasedCulture = true;
});
// WASM client project — no UseRouteBasedCulture configuration needed.
// The WASM client reads the flag from the server via /api/abp/application-configuration.
```
## Multi-Tenancy
URL-based localization is fully compatible with ABP's multi-tenant routing. The culture route is handled as a separate routing layer from the tenant. Language switching explicitly supports tenant-prefixed URLs, so `/tenant-a/zh-Hans/About → /tenant-a/en/About` works without any additional configuration.
> For details on combining tenant routing with culture routing, see the [Multi-Tenancy](https://docs.abp.io/en/abp/latest/Multi-Tenancy) documentation.
> For details on combining tenant routing with culture routing, see the [Multi-Tenancy](https://abp.io/docs/latest/framework/architecture/multi-tenancy) documentation.
## UI Framework Support Overview
| UI Framework | Route Registration | URL Generation | Menu URLs | Language Switch | Manual Work |
|---|---|---|---|---|---|
| **MVC / Razor Pages** | Automatic | Automatic | Automatic | Automatic | None |
| **Blazor Server** | Manual `@page` routes | N/A | Automatic | Automatic (forceLoad) | Add `{culture}` route to pages |
| **Blazor WebApp (WASM)** | Manual `@page` routes | N/A | Automatic | Automatic (forceLoad) | Add `{culture}` route to pages |
| **MVC / Razor Pages** | Automatic | Automatic | Automatic | Server-side redirect | None |
| **Blazor Server** | Manual `@page` routes | N/A | Automatic | Server-side redirect (forceLoad) | Add `{culture}` route to pages |
| **Blazor WebApp (WASM)** | Manual `@page` routes | N/A | Automatic | Client-side URL replace (forceLoad) | Add `{culture}` route to pages |
## Summary
@ -177,8 +193,8 @@ A runnable sample demonstrating this feature is available at [abp-samples/UrlBas
## References
- [URL-Based Localization — ABP Documentation](https://docs.abp.io/en/abp/latest/URL-Based-Localization)
- [Localization — ABP Documentation](https://docs.abp.io/en/abp/latest/Localization)
- [URL-Based Localization — ABP Documentation](https://abp.io/docs/latest/framework/fundamentals/url-based-localization)
- [Localization — ABP Documentation](https://abp.io/docs/latest/framework/fundamentals/localization)
- [abp-samples/UrlBasedLocalization — GitHub](https://github.com/abpframework/abp-samples/tree/master/UrlBasedLocalization)
- [Request Localization in ASP.NET Core](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/localization/select-language-culture)
- [IETF BCP 47 Language Tags](https://www.rfc-editor.org/info/bcp47)

35
docs/en/framework/fundamentals/url-based-localization.md

@ -34,7 +34,8 @@ When you set `UseRouteBasedCulture` to `true`, ABP automatically registers the f
* **`{culture}/{controller}/{action}` route** — A conventional route for MVC controllers.
* **`AbpCultureRoutePagesConvention`** — An `IPageRouteModelConvention` that adds `{culture}/...` route selectors to all Razor Pages.
* **`AbpCultureRouteUrlHelperFactory`** — Replaces the default `IUrlHelperFactory` to auto-inject culture into `Url.Page()` and `Url.Action()` calls.
* **`AbpCultureMenuItemUrlProvider`** — Prepends the culture prefix to navigation menu item URLs.
* **`AbpCultureMenuItemUrlProvider`** — Prepends the culture prefix to navigation menu item URLs (MVC / Blazor Server).
* **`AbpWasmCultureMenuItemUrlProvider`** — Prepends the culture prefix to menu item URLs in Blazor WebAssembly (reads the `UseRouteBasedCulture` flag from `/api/abp/application-configuration`).
You do not need to configure these individually.
@ -68,7 +69,7 @@ Menu items registered via `IMenuContributor` also automatically get the culture
## Language Switching
ABP's built-in language switcher (the `/Abp/Languages/Switch` action) automatically replaces the culture segment in the `returnUrl`. The controller reads `CultureInfo.CurrentCulture` to identify the current culture and replaces it with the new one:
ABP's built-in language switcher (the `/Abp/Languages/Switch` action) automatically replaces the culture segment in the `returnUrl`. The controller reads the culture from the request cookie to identify the current page culture and replaces it with the new one:
| Before switching | After switching to English |
|---|---|
@ -172,8 +173,9 @@ Blazor WebAssembly (WASM) runs in the browser. On the **first page load**, the s
|---|---|
| **SSR culture detection** | Same as Blazor Server — `RouteDataRequestCultureProvider` reads `{culture}` on the initial HTTP request. |
| **Cookie persistence** | The cookie is set during SSR, ensuring the WASM client inherits the correct culture. |
| **Menu URLs** | `AbpCultureMenuItemUrlProvider` handles culture prefix for menu items. |
| **Language switching** | Uses the server's `/Abp/Languages/Switch` endpoint, which rewrites the culture segment in the URL and redirects. |
| **Menu URLs** | `AbpWasmCultureMenuItemUrlProvider` prepends the culture prefix to menu items. The `UseRouteBasedCulture` flag is read from `/api/abp/application-configuration`. |
| **Language switching** | The built-in `LanguageSwitch` component replaces the culture segment in the current URL and navigates with `forceLoad: true`, triggering a full page reload with the new culture. |
| **Login link** | The `LoginDisplay` component automatically prepends the culture prefix to the login URL when route-based culture is active. |
### What requires manual changes
@ -188,7 +190,8 @@ Configure<AbpRequestLocalizationOptions>(options =>
options.UseRouteBasedCulture = true;
});
// WASM client project — no special configuration needed
// WASM client project — no special UseRouteBasedCulture configuration needed.
// The WASM client reads the flag from the server via /api/abp/application-configuration.
````
### Example middleware pipeline
@ -216,29 +219,19 @@ Language switching also supports tenant-prefixed URLs. For example, `/tenant-a/z
Routes like `/api/products` have no `{culture}` segment, so `RouteDataRequestCultureProvider` returns `null` and falls through to the next provider (Cookie → `Accept-Language` → default). API routes are completely unaffected.
## FAQ
## Culture Detection Priority
### What happens with an invalid culture code in the URL?
If `/xyz1234/page` is requested and `xyz1234` is not a valid culture, `RequestLocalizationMiddleware` ignores it and falls through to the default culture. No error is thrown.
### Can I mix URL-based and QueryString-based culture detection?
Yes. All providers work together in priority order:
All request culture providers work together in priority order. When `UseRouteBasedCulture` is enabled, the route-based provider is added as the highest priority:
1. `RouteDataRequestCultureProvider` (URL path — highest priority when enabled)
2. `QueryStringRequestCultureProvider`
3. `CookieRequestCultureProvider`
4. `AcceptLanguageHeaderRequestCultureProvider`
### Should I keep both localized and non-localized routes?
Yes. ABP automatically registers both `{culture}/{controller}/{action}` and `{controller}/{action}` routes. The second route handles direct navigation to `/` and any controller action that doesn't have a culture prefix.
### Why do Blazor pages need manual `@page "/{culture}/..."` routes?
If a URL contains an invalid culture code (e.g. `/xyz1234/page`), `RequestLocalizationMiddleware` ignores it and falls through to the next provider. No error is thrown.
ASP.NET Core does not provide an `IPageRouteModelConvention` equivalent for Blazor components. Razor Pages routes are discovered through `PageRouteModel` which supports conventions, but Blazor component routes are compiled from `@page` directives into `[RouteAttribute]` at build time, with no runtime extension point. This is an ASP.NET Core platform limitation.
## Route Registration
### Do I need to add `{culture}` routes to ABP module pages (Identity, Settings, etc.)?
ABP automatically registers both `{culture}/{controller}/{action}` and `{controller}/{action}` routes. The non-prefixed route handles direct navigation to `/` and URLs without a culture segment.
No. ABP built-in module pages already ship with `@page "/{culture}/..."` route variants. You only need to add these routes to your own application pages.
> ABP built-in module pages (Identity, Tenant Management, Settings, Account, etc.) already include `@page "/{culture}/..."` route variants out of the box. You only need to add these routes to your own application pages.

75
framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/AbpWasmCultureMenuItemUrlProvider.cs

@ -0,0 +1,75 @@
using System;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using Volo.Abp.AspNetCore.Mvc.Client;
using Volo.Abp.DependencyInjection;
using Volo.Abp.UI.Navigation;
namespace Volo.Abp.AspNetCore.Components.WebAssembly.Theming;
/// <summary>
/// Prepends the culture route prefix to menu item URLs in Blazor WebAssembly when route-based culture is enabled.
/// </summary>
public class AbpWasmCultureMenuItemUrlProvider : IMenuItemUrlProvider, ITransientDependency
{
protected ICachedApplicationConfigurationClient ConfigurationClient { get; }
public AbpWasmCultureMenuItemUrlProvider(
ICachedApplicationConfigurationClient configurationClient)
{
ConfigurationClient = configurationClient;
}
public virtual async Task HandleAsync(MenuItemUrlProviderContext context)
{
var config = await ConfigurationClient.GetAsync();
if (!config.Localization.UseRouteBasedCulture)
{
return;
}
var culture = GetCulture(config);
if (string.IsNullOrEmpty(culture))
{
return;
}
PrependCulturePrefix(context.Menu, "/" + culture);
}
protected virtual string? GetCulture(Mvc.ApplicationConfigurations.ApplicationConfigurationDto config)
{
var currentCulture = CultureInfo.CurrentCulture.Name;
var languages = config.Localization.Languages;
if (languages.Count == 0)
{
return null;
}
var isKnownCulture = languages
.Any(l => string.Equals(l.CultureName, currentCulture, StringComparison.OrdinalIgnoreCase));
return isKnownCulture ? currentCulture : null;
}
protected virtual void PrependCulturePrefix(IHasMenuItems menuWithItems, string prefix)
{
foreach (var item in menuWithItems.Items)
{
if (item.Url != null)
{
if (item.Url.StartsWith("~/"))
{
item.Url = "~" + prefix + item.Url[1..];
}
else if (item.Url.StartsWith('/'))
{
item.Url = prefix + item.Url;
}
}
PrependCulturePrefix(item, prefix);
}
}
}

2
framework/src/Volo.Abp.AspNetCore.Mvc.Contracts/Volo/Abp/AspNetCore/Mvc/ApplicationConfigurations/ApplicationLocalizationConfigurationDto.cs

@ -34,6 +34,8 @@ public class ApplicationLocalizationConfigurationDto
public Dictionary<string, List<NameValue>> LanguageFilesMap { get; set; }
public bool UseRouteBasedCulture { get; set; }
public ApplicationLocalizationConfigurationDto()
{
Values = new Dictionary<string, Dictionary<string, string>>();

1
framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AbpAspNetCoreMvcModule.cs

@ -248,7 +248,6 @@ public class AbpAspNetCoreMvcModule : AbpModule
context.Services.TryAddSingleton<UrlHelperFactory>();
context.Services.Replace(ServiceDescriptor.Singleton<IUrlHelperFactory, AbpCultureRouteUrlHelperFactory>());
context.Services.AddTransient<IMenuItemUrlProvider, AbpCultureMenuItemUrlProvider>();
}
public override void OnApplicationInitialization(ApplicationInitializationContext context)

5
framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApplicationConfigurations/AbpApplicationConfigurationAppService.cs

@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.RequestLocalization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Options;
@ -27,6 +28,7 @@ namespace Volo.Abp.AspNetCore.Mvc.ApplicationConfigurations;
public class AbpApplicationConfigurationAppService : ApplicationService, IAbpApplicationConfigurationAppService
{
private readonly AbpLocalizationOptions _localizationOptions;
private readonly AbpRequestLocalizationOptions _requestLocalizationOptions;
private readonly AbpMultiTenancyOptions _multiTenancyOptions;
private readonly IServiceProvider _serviceProvider;
private readonly IAbpAuthorizationPolicyProvider _abpAuthorizationPolicyProvider;
@ -46,6 +48,7 @@ public class AbpApplicationConfigurationAppService : ApplicationService, IAbpApp
public AbpApplicationConfigurationAppService(
IOptions<AbpLocalizationOptions> localizationOptions,
IOptions<AbpRequestLocalizationOptions> requestLocalizationOptions,
IOptions<AbpMultiTenancyOptions> multiTenancyOptions,
IServiceProvider serviceProvider,
IAbpAuthorizationPolicyProvider abpAuthorizationPolicyProvider,
@ -79,6 +82,7 @@ public class AbpApplicationConfigurationAppService : ApplicationService, IAbpApp
_cachedObjectExtensionsDtoService = cachedObjectExtensionsDtoService;
_options = options.Value;
_localizationOptions = localizationOptions.Value;
_requestLocalizationOptions = requestLocalizationOptions.Value;
_multiTenancyOptions = multiTenancyOptions.Value;
}
@ -253,6 +257,7 @@ public class AbpApplicationConfigurationAppService : ApplicationService, IAbpApp
localizationConfig.LanguagesMap = _localizationOptions.LanguagesMap;
localizationConfig.LanguageFilesMap = _localizationOptions.LanguageFilesMap;
localizationConfig.UseRouteBasedCulture = _requestLocalizationOptions.UseRouteBasedCulture;
return localizationConfig;
}

23
framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpAspNetCoreMvcQueryStringCultureReplacement.cs

@ -6,8 +6,17 @@ using Volo.Abp.DependencyInjection;
namespace Volo.Abp.AspNetCore.Mvc.Localization;
public class AbpAspNetCoreMvcQueryStringCultureReplacement : IQueryStringCultureReplacement, ITransientDependency
public partial 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))
@ -32,17 +41,13 @@ public class AbpAspNetCoreMvcQueryStringCultureReplacement : IQueryStringCulture
if (context.ReturnUrl.Contains("culture=", StringComparison.OrdinalIgnoreCase) &&
context.ReturnUrl.Contains("ui-Culture=", StringComparison.OrdinalIgnoreCase))
{
context.ReturnUrl = Regex.Replace(
context.ReturnUrl = CultureQueryStringRegex.Replace(
context.ReturnUrl,
"culture=[A-Za-z-]+",
$"culture={context.RequestCulture.Culture}",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
$"culture={context.RequestCulture.Culture}");
context.ReturnUrl = Regex.Replace(
context.ReturnUrl = UiCultureQueryStringRegex.Replace(
context.ReturnUrl,
"ui-culture=[A-Za-z-]+",
$"ui-culture={context.RequestCulture.UICulture}",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
$"ui-culture={context.RequestCulture.UICulture}");
}
return Task.CompletedTask;

25
framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureMenuItemUrlProvider.cs

@ -6,17 +6,16 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.RequestLocalization;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Options;
using Volo.Abp.DependencyInjection;
using Volo.Abp.Localization;
using Volo.Abp.UI.Navigation;
namespace Volo.Abp.AspNetCore.Mvc.Localization;
/// <summary>
/// Prepends the culture route prefix to all local menu item URLs
/// when the current request has a {culture} route value.
/// Only activates when <see cref="AbpRequestLocalizationOptions.UseRouteBasedCulture"/> is <c>true</c>.
/// Prepends the culture route prefix to menu item URLs when route-based culture is enabled.
/// </summary>
public class AbpCultureMenuItemUrlProvider : IMenuItemUrlProvider
public class AbpCultureMenuItemUrlProvider : IMenuItemUrlProvider, ITransientDependency
{
protected IHttpContextAccessor HttpContextAccessor { get; }
protected IOptions<AbpRequestLocalizationOptions> LocalizationOptions { get; }
@ -56,15 +55,10 @@ public class AbpCultureMenuItemUrlProvider : IMenuItemUrlProvider
var httpContext = HttpContextAccessor.HttpContext;
if (httpContext != null)
{
// MVC, Razor Pages, or Blazor SSR — read from route data.
// If no {culture} route value, the URL has no culture prefix → return null.
return httpContext.GetRouteValue("culture")?.ToString();
}
// Blazor interactive circuits: HttpContext is null because there is
// no active HTTP request. Fall back to CultureInfo.CurrentCulture
// (set by the middleware during SSR and persisted in the circuit).
// CurrentCulture corresponds to the {culture} route segment, not ui-culture.
// No HttpContext: Blazor interactive circuit or WASM client.
var currentCulture = CultureInfo.CurrentCulture.Name;
var isKnownCulture = AbpLocalizationOptions.Value.Languages
.Any(l => string.Equals(l.CultureName, currentCulture, StringComparison.OrdinalIgnoreCase));
@ -76,9 +70,16 @@ public class AbpCultureMenuItemUrlProvider : IMenuItemUrlProvider
{
foreach (var item in menuWithItems.Items)
{
if (item.Url != null && item.Url.StartsWith('/'))
if (item.Url != null)
{
item.Url = prefix + item.Url;
if (item.Url.StartsWith("~/"))
{
item.Url = "~" + prefix + item.Url[1..];
}
else if (item.Url.StartsWith('/'))
{
item.Url = prefix + item.Url;
}
}
PrependCulturePrefix(item, prefix);

58
framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureMenuItemUrlProvider_Tests.cs

@ -38,9 +38,10 @@ public class AbpCultureMenuItemUrlProvider_Tests
}
[Fact]
public async Task Should_Not_Add_Prefix_When_No_Culture_Route_Value()
public async Task Should_Not_Add_Prefix_When_HttpContext_Has_No_Culture_Route()
{
// MVC request with no {culture} route value (e.g. user visits /About directly)
// HttpContext exists but has no {culture} route value (e.g. MVC request to /about).
// No prefix should be added to keep URL style consistent with the current page.
var provider = CreateProvider(useRouteBasedCulture: true, cultureName: null);
var menu = CreateMenuWithItems("/home", "/about");
@ -50,6 +51,40 @@ public class AbpCultureMenuItemUrlProvider_Tests
menu.Items[1].Url.ShouldBe("/about");
}
[Fact]
public async Task Should_Not_Add_Prefix_When_HttpContext_Has_No_Culture_Route_Even_With_Known_Languages()
{
// HttpContext exists but has no {culture} route value, even though CurrentCulture
// matches a known language. No prefix should be added because the current request
// does not have a culture segment in the URL.
var httpContext = new DefaultHttpContext(); // no culture route value
var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext };
var localizationOptions = MsOptions.Create(
new AbpRequestLocalizationOptions { UseRouteBasedCulture = true });
var abpLocOptions = new AbpLocalizationOptions();
abpLocOptions.Languages.Add(new LanguageInfo("en"));
abpLocOptions.Languages.Add(new LanguageInfo("zh-Hans"));
abpLocOptions.Languages.Add(new LanguageInfo("tr"));
var provider = new AbpCultureMenuItemUrlProvider(
httpContextAccessor, localizationOptions, MsOptions.Create(abpLocOptions));
var menu = CreateMenuWithItems("/home", "/about");
var previousCulture = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = new CultureInfo("zh-Hans");
await provider.HandleAsync(new MenuItemUrlProviderContext(menu));
}
finally
{
CultureInfo.CurrentCulture = previousCulture;
}
menu.Items[0].Url.ShouldBe("/home");
menu.Items[1].Url.ShouldBe("/about");
}
[Fact]
public async Task Should_Use_CurrentCulture_Fallback_When_No_HttpContext()
{
@ -122,6 +157,25 @@ public class AbpCultureMenuItemUrlProvider_Tests
grandChild.Url.ShouldBe("/tr/grandchild");
}
[Fact]
public async Task Should_Handle_Tilde_Slash_Urls()
{
// ~/identity/users is the pattern used by ABP module menu contributors (e.g. Identity)
var provider = CreateProvider(useRouteBasedCulture: true, cultureName: "zh-Hans");
var menu = new ApplicationMenu("TestMenu");
menu.AddItem(new ApplicationMenuItem("Users", "Users", url: "~/identity/users"));
menu.AddItem(new ApplicationMenuItem("Roles", "Roles", url: "~/identity/roles"));
await provider.HandleAsync(new MenuItemUrlProviderContext(menu));
// ~/identity/users → ~/zh-Hans/identity/users
// Blazor theme strips "~/" via TrimStart('/', '~') → "zh-Hans/identity/users"
// With <base href="/"> resolves to /zh-Hans/identity/users
menu.Items[0].Url.ShouldBe("~/zh-Hans/identity/users");
menu.Items[1].Url.ShouldBe("~/zh-Hans/identity/roles");
}
[Fact]
public async Task Should_Not_Modify_External_Urls()
{

2
modules/account/src/Volo.Abp.Account.Blazor/AbpAccountBlazorUserMenuContributor.cs

@ -15,7 +15,7 @@ public class AbpAccountBlazorUserMenuContributor : IMenuContributor
var accountResource = context.GetLocalizer<AccountResource>();
context.Menu.AddItem(new ApplicationMenuItem("Account.Manage", accountResource["MyAccount"], url: "account/manage-profile", icon: "fa fa-cog"));
context.Menu.AddItem(new ApplicationMenuItem("Account.Manage", accountResource["MyAccount"], url: "~/account/manage-profile", icon: "fa fa-cog"));
return Task.CompletedTask;
}

2
modules/basic-theme/src/Volo.Abp.AspNetCore.Components.Server.BasicTheme/Themes/Basic/LoginDisplay.razor

@ -33,6 +33,6 @@
</Dropdown>
</Authorized>
<NotAuthorized>
<a class="nav-link" href="Account/Login">@L["Login"]</a>
<a class="nav-link" href="@GetLoginUrl()">@L["Login"]</a>
</NotAuthorized>
</AuthorizeView>

11
modules/basic-theme/src/Volo.Abp.AspNetCore.Components.Server.BasicTheme/Themes/Basic/LoginDisplay.razor.cs

@ -2,6 +2,8 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Routing;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Volo.Abp.UI.Navigation;
namespace Volo.Abp.AspNetCore.Components.Server.BasicTheme.Themes.Basic;
@ -11,6 +13,9 @@ public partial class LoginDisplay : IDisposable
[Inject]
protected IMenuManager MenuManager { get; set; }
[Inject]
protected IHttpContextAccessor HttpContextAccessor { get; set; }
protected ApplicationMenu Menu { get; set; }
protected override async Task OnInitializedAsync()
@ -20,6 +25,12 @@ public partial class LoginDisplay : IDisposable
Navigation.LocationChanged += OnLocationChanged;
}
protected string GetLoginUrl()
{
var culture = HttpContextAccessor.HttpContext?.GetRouteValue("culture")?.ToString();
return string.IsNullOrEmpty(culture) ? "Account/Login" : $"{culture}/Account/Login";
}
protected virtual void OnLocationChanged(object sender, LocationChangedEventArgs e)
{
InvokeAsync(StateHasChanged);

27
modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/Themes/Basic/LanguageSwitch.razor

@ -1,10 +1,13 @@
@using Volo.Abp.Localization
@using Volo.Abp.Localization
@using System.Globalization
@using System.Collections.Immutable
@using Volo.Abp.AspNetCore.Components.Web
@using Volo.Abp.AspNetCore.Mvc.Client
@inject ILanguageProvider LanguageProvider
@inject IJSRuntime JsRuntime
@inject ICookieService CookieService
@inject NavigationManager NavigationManager
@inject ICachedApplicationConfigurationClient ConfigurationClient
@if (_otherLanguages != null && _otherLanguages.Any())
{
<BarDropdown RightAligned="true">
@ -22,9 +25,13 @@
@code {
private IReadOnlyList<LanguageInfo> _otherLanguages;
private LanguageInfo _currentLanguage;
private bool _useRouteBasedCulture;
protected override async Task OnInitializedAsync()
{
var config = await ConfigurationClient.GetAsync();
_useRouteBasedCulture = config.Localization.UseRouteBasedCulture;
var selectedLanguageName = await JsRuntime.InvokeAsync<string>(
"localStorage.getItem",
"Abp.SelectedLanguage"
@ -57,6 +64,24 @@
private async Task ChangeLanguageAsync(LanguageInfo language)
{
if (_useRouteBasedCulture)
{
var relativePath = NavigationManager.ToBaseRelativePath(NavigationManager.Uri);
var slashIndex = relativePath.IndexOf('/');
var firstSegment = slashIndex >= 0 ? relativePath[..slashIndex] : relativePath;
var allCultures = _otherLanguages
.Select(l => l.CultureName)
.Append(_currentLanguage.CultureName);
var newRelativePath = allCultures.Any(c => string.Equals(c, firstSegment, StringComparison.OrdinalIgnoreCase))
? language.CultureName + (slashIndex >= 0 ? relativePath[slashIndex..] : string.Empty)
: language.CultureName + "/" + relativePath;
NavigationManager.NavigateTo(NavigationManager.ToAbsoluteUri(newRelativePath).ToString(), forceLoad: true);
return;
}
await JsRuntime.InvokeVoidAsync(
"localStorage.setItem",
"Abp.SelectedLanguage",

2
modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/Themes/Basic/LoginDisplay.razor

@ -35,6 +35,6 @@
</Dropdown>
</Authorized>
<NotAuthorized>
<a class="nav-link" href="@AuthenticationOptions.Value.LoginUrl">@UiLocalizer["Login"]</a>
<a class="nav-link" href="@LoginUrl">@UiLocalizer["Login"]</a>
</NotAuthorized>
</AuthorizeView>

31
modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/Themes/Basic/LoginDisplay.razor.cs

@ -1,10 +1,14 @@
using System;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Routing;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
using Microsoft.JSInterop;
using Volo.Abp.AspNetCore.Components.Web.Security;
using Volo.Abp.AspNetCore.Mvc.ApplicationConfigurations;
using Volo.Abp.AspNetCore.Mvc.Client;
using Volo.Abp.UI.Navigation;
namespace Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme.Themes.Basic;
@ -17,10 +21,35 @@ public partial class LoginDisplay : IDisposable
[Inject]
protected ApplicationConfigurationChangedService ApplicationConfigurationChangedService { get; set; }
[Inject]
protected ICachedApplicationConfigurationClient ConfigurationClient { get; set; }
protected ApplicationMenu Menu { get; set; }
private ApplicationConfigurationDto _config;
protected string LoginUrl
{
get
{
var loginUrl = AuthenticationOptions.Value.LoginUrl;
if (_config?.Localization.UseRouteBasedCulture != true)
{
return loginUrl;
}
var currentCulture = CultureInfo.CurrentCulture.Name;
var isKnownCulture = _config.Localization.Languages
.Any(l => string.Equals(l.CultureName, currentCulture, StringComparison.OrdinalIgnoreCase));
return isKnownCulture ? $"{currentCulture}/{loginUrl}" : loginUrl;
}
}
protected async override Task OnInitializedAsync()
{
_config = await ConfigurationClient.GetAsync();
Menu = await MenuManager.GetAsync(StandardMenus.User);
Navigation.LocationChanged += OnLocationChanged;
@ -53,7 +82,7 @@ public partial class LoginDisplay : IDisposable
}
else
{
Navigation.NavigateTo(uri);
Navigation.NavigateTo(uri?.TrimStart('~', '/') ?? uri);
}
}

Loading…
Cancel
Save