Browse Source

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

pull/25174/head
maliming 19 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 ### What works automatically
| Feature | How it works | | Feature | Blazor Server | Blazor WebApp (WASM) |
|---|---| |---|---|---|
| **Culture detection** | `RouteDataRequestCultureProvider` reads `{culture}` from the URL on the initial HTTP request (SSR). | | **Culture detection** | `RouteDataRequestCultureProvider` reads `{culture}` on the initial HTTP request (SSR). | Same — SSR on first load. |
| **Cookie persistence** | The middleware saves the detected culture to the `.AspNetCore.Culture` cookie, which persists across the WebSocket connection. | | **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. In Blazor interactive circuits (where `HttpContext` is null — no active HTTP request), it falls back to `CultureInfo.CurrentCulture`. | | **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** | 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. | | **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 ### 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 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 ```csharp
// ABP BasicTheme LanguageSwitch.razor
NavigationManager.NavigateTo( NavigationManager.NavigateTo(
$"Abp/Languages/Switch?culture={language.CultureName}&uiCulture={language.UiCultureName}&returnUrl={relativeUrl}", $"Abp/Languages/Switch?culture={language.CultureName}&uiCulture={language.UiCultureName}&returnUrl={relativeUrl}",
forceLoad: true 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. Normal page-to-page navigation (within the same language) remains client-side and fast. Only language switching triggers a reload.
### Example module configuration ### Example module configuration
```csharp ```csharp
// Server project
Configure<AbpRequestLocalizationOptions>(options => Configure<AbpRequestLocalizationOptions>(options =>
{ {
options.UseRouteBasedCulture = true; 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 ## 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. 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 Support Overview
| UI Framework | Route Registration | URL Generation | Menu URLs | Language Switch | Manual Work | | UI Framework | Route Registration | URL Generation | Menu URLs | Language Switch | Manual Work |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| **MVC / Razor Pages** | Automatic | Automatic | Automatic | Automatic | None | | **MVC / Razor Pages** | Automatic | Automatic | Automatic | Server-side redirect | None |
| **Blazor Server** | Manual `@page` routes | N/A | Automatic | Automatic (forceLoad) | Add `{culture}` route to pages | | **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 | Automatic (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 ## Summary
@ -177,8 +193,8 @@ A runnable sample demonstrating this feature is available at [abp-samples/UrlBas
## References ## References
- [URL-Based Localization — ABP Documentation](https://docs.abp.io/en/abp/latest/URL-Based-Localization) - [URL-Based Localization — ABP Documentation](https://abp.io/docs/latest/framework/fundamentals/url-based-localization)
- [Localization — ABP Documentation](https://docs.abp.io/en/abp/latest/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) - [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) - [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) - [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. * **`{culture}/{controller}/{action}` route** — A conventional route for MVC controllers.
* **`AbpCultureRoutePagesConvention`** — An `IPageRouteModelConvention` that adds `{culture}/...` route selectors to all Razor Pages. * **`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. * **`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. You do not need to configure these individually.
@ -68,7 +69,7 @@ Menu items registered via `IMenuContributor` also automatically get the culture
## Language Switching ## 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 | | 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. | | **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. | | **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. | | **Menu URLs** | `AbpWasmCultureMenuItemUrlProvider` prepends the culture prefix to menu items. The `UseRouteBasedCulture` flag is read from `/api/abp/application-configuration`. |
| **Language switching** | Uses the server's `/Abp/Languages/Switch` endpoint, which rewrites the culture segment in the URL and redirects. | | **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 ### What requires manual changes
@ -188,7 +190,8 @@ Configure<AbpRequestLocalizationOptions>(options =>
options.UseRouteBasedCulture = true; 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 ### 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. 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? All request culture providers work together in priority order. When `UseRouteBasedCulture` is enabled, the route-based provider is added as the highest priority:
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:
1. `RouteDataRequestCultureProvider` (URL path — highest priority when enabled) 1. `RouteDataRequestCultureProvider` (URL path — highest priority when enabled)
2. `QueryStringRequestCultureProvider` 2. `QueryStringRequestCultureProvider`
3. `CookieRequestCultureProvider` 3. `CookieRequestCultureProvider`
4. `AcceptLanguageHeaderRequestCultureProvider` 4. `AcceptLanguageHeaderRequestCultureProvider`
### Should I keep both localized and non-localized 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.
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?
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 Dictionary<string, List<NameValue>> LanguageFilesMap { get; set; }
public bool UseRouteBasedCulture { get; set; }
public ApplicationLocalizationConfigurationDto() public ApplicationLocalizationConfigurationDto()
{ {
Values = new Dictionary<string, Dictionary<string, string>>(); 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.TryAddSingleton<UrlHelperFactory>();
context.Services.Replace(ServiceDescriptor.Singleton<IUrlHelperFactory, AbpCultureRouteUrlHelperFactory>()); context.Services.Replace(ServiceDescriptor.Singleton<IUrlHelperFactory, AbpCultureRouteUrlHelperFactory>());
context.Services.AddTransient<IMenuItemUrlProvider, AbpCultureMenuItemUrlProvider>();
} }
public override void OnApplicationInitialization(ApplicationInitializationContext context) 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.Authorization;
using Microsoft.AspNetCore.RequestLocalization;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Localization; using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@ -27,6 +28,7 @@ namespace Volo.Abp.AspNetCore.Mvc.ApplicationConfigurations;
public class AbpApplicationConfigurationAppService : ApplicationService, IAbpApplicationConfigurationAppService public class AbpApplicationConfigurationAppService : ApplicationService, IAbpApplicationConfigurationAppService
{ {
private readonly AbpLocalizationOptions _localizationOptions; private readonly AbpLocalizationOptions _localizationOptions;
private readonly AbpRequestLocalizationOptions _requestLocalizationOptions;
private readonly AbpMultiTenancyOptions _multiTenancyOptions; private readonly AbpMultiTenancyOptions _multiTenancyOptions;
private readonly IServiceProvider _serviceProvider; private readonly IServiceProvider _serviceProvider;
private readonly IAbpAuthorizationPolicyProvider _abpAuthorizationPolicyProvider; private readonly IAbpAuthorizationPolicyProvider _abpAuthorizationPolicyProvider;
@ -46,6 +48,7 @@ public class AbpApplicationConfigurationAppService : ApplicationService, IAbpApp
public AbpApplicationConfigurationAppService( public AbpApplicationConfigurationAppService(
IOptions<AbpLocalizationOptions> localizationOptions, IOptions<AbpLocalizationOptions> localizationOptions,
IOptions<AbpRequestLocalizationOptions> requestLocalizationOptions,
IOptions<AbpMultiTenancyOptions> multiTenancyOptions, IOptions<AbpMultiTenancyOptions> multiTenancyOptions,
IServiceProvider serviceProvider, IServiceProvider serviceProvider,
IAbpAuthorizationPolicyProvider abpAuthorizationPolicyProvider, IAbpAuthorizationPolicyProvider abpAuthorizationPolicyProvider,
@ -79,6 +82,7 @@ public class AbpApplicationConfigurationAppService : ApplicationService, IAbpApp
_cachedObjectExtensionsDtoService = cachedObjectExtensionsDtoService; _cachedObjectExtensionsDtoService = cachedObjectExtensionsDtoService;
_options = options.Value; _options = options.Value;
_localizationOptions = localizationOptions.Value; _localizationOptions = localizationOptions.Value;
_requestLocalizationOptions = requestLocalizationOptions.Value;
_multiTenancyOptions = multiTenancyOptions.Value; _multiTenancyOptions = multiTenancyOptions.Value;
} }
@ -253,6 +257,7 @@ public class AbpApplicationConfigurationAppService : ApplicationService, IAbpApp
localizationConfig.LanguagesMap = _localizationOptions.LanguagesMap; localizationConfig.LanguagesMap = _localizationOptions.LanguagesMap;
localizationConfig.LanguageFilesMap = _localizationOptions.LanguageFilesMap; localizationConfig.LanguageFilesMap = _localizationOptions.LanguageFilesMap;
localizationConfig.UseRouteBasedCulture = _requestLocalizationOptions.UseRouteBasedCulture;
return localizationConfig; 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; 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) public virtual Task ReplaceAsync(QueryStringCultureReplacementContext context)
{ {
if (string.IsNullOrWhiteSpace(context.ReturnUrl)) if (string.IsNullOrWhiteSpace(context.ReturnUrl))
@ -32,17 +41,13 @@ public class AbpAspNetCoreMvcQueryStringCultureReplacement : IQueryStringCulture
if (context.ReturnUrl.Contains("culture=", StringComparison.OrdinalIgnoreCase) && if (context.ReturnUrl.Contains("culture=", StringComparison.OrdinalIgnoreCase) &&
context.ReturnUrl.Contains("ui-Culture=", StringComparison.OrdinalIgnoreCase)) context.ReturnUrl.Contains("ui-Culture=", StringComparison.OrdinalIgnoreCase))
{ {
context.ReturnUrl = Regex.Replace( context.ReturnUrl = CultureQueryStringRegex.Replace(
context.ReturnUrl, context.ReturnUrl,
"culture=[A-Za-z-]+", $"culture={context.RequestCulture.Culture}");
$"culture={context.RequestCulture.Culture}",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
context.ReturnUrl = Regex.Replace( context.ReturnUrl = UiCultureQueryStringRegex.Replace(
context.ReturnUrl, context.ReturnUrl,
"ui-culture=[A-Za-z-]+", $"ui-culture={context.RequestCulture.UICulture}");
$"ui-culture={context.RequestCulture.UICulture}",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
} }
return Task.CompletedTask; 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.RequestLocalization;
using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Volo.Abp.DependencyInjection;
using Volo.Abp.Localization; using Volo.Abp.Localization;
using Volo.Abp.UI.Navigation; using Volo.Abp.UI.Navigation;
namespace Volo.Abp.AspNetCore.Mvc.Localization; namespace Volo.Abp.AspNetCore.Mvc.Localization;
/// <summary> /// <summary>
/// Prepends the culture route prefix to all local menu item URLs /// Prepends the culture route prefix to menu item URLs when route-based culture is enabled.
/// when the current request has a {culture} route value.
/// Only activates when <see cref="AbpRequestLocalizationOptions.UseRouteBasedCulture"/> is <c>true</c>.
/// </summary> /// </summary>
public class AbpCultureMenuItemUrlProvider : IMenuItemUrlProvider public class AbpCultureMenuItemUrlProvider : IMenuItemUrlProvider, ITransientDependency
{ {
protected IHttpContextAccessor HttpContextAccessor { get; } protected IHttpContextAccessor HttpContextAccessor { get; }
protected IOptions<AbpRequestLocalizationOptions> LocalizationOptions { get; } protected IOptions<AbpRequestLocalizationOptions> LocalizationOptions { get; }
@ -56,15 +55,10 @@ public class AbpCultureMenuItemUrlProvider : IMenuItemUrlProvider
var httpContext = HttpContextAccessor.HttpContext; var httpContext = HttpContextAccessor.HttpContext;
if (httpContext != null) 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(); return httpContext.GetRouteValue("culture")?.ToString();
} }
// Blazor interactive circuits: HttpContext is null because there is // No HttpContext: Blazor interactive circuit or WASM client.
// 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.
var currentCulture = CultureInfo.CurrentCulture.Name; var currentCulture = CultureInfo.CurrentCulture.Name;
var isKnownCulture = AbpLocalizationOptions.Value.Languages var isKnownCulture = AbpLocalizationOptions.Value.Languages
.Any(l => string.Equals(l.CultureName, currentCulture, StringComparison.OrdinalIgnoreCase)); .Any(l => string.Equals(l.CultureName, currentCulture, StringComparison.OrdinalIgnoreCase));
@ -76,9 +70,16 @@ public class AbpCultureMenuItemUrlProvider : IMenuItemUrlProvider
{ {
foreach (var item in menuWithItems.Items) 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); 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] [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 provider = CreateProvider(useRouteBasedCulture: true, cultureName: null);
var menu = CreateMenuWithItems("/home", "/about"); var menu = CreateMenuWithItems("/home", "/about");
@ -50,6 +51,40 @@ public class AbpCultureMenuItemUrlProvider_Tests
menu.Items[1].Url.ShouldBe("/about"); 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] [Fact]
public async Task Should_Use_CurrentCulture_Fallback_When_No_HttpContext() public async Task Should_Use_CurrentCulture_Fallback_When_No_HttpContext()
{ {
@ -122,6 +157,25 @@ public class AbpCultureMenuItemUrlProvider_Tests
grandChild.Url.ShouldBe("/tr/grandchild"); 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] [Fact]
public async Task Should_Not_Modify_External_Urls() 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>(); 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; return Task.CompletedTask;
} }

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

@ -33,6 +33,6 @@
</Dropdown> </Dropdown>
</Authorized> </Authorized>
<NotAuthorized> <NotAuthorized>
<a class="nav-link" href="Account/Login">@L["Login"]</a> <a class="nav-link" href="@GetLoginUrl()">@L["Login"]</a>
</NotAuthorized> </NotAuthorized>
</AuthorizeView> </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 System.Threading.Tasks;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Routing; using Microsoft.AspNetCore.Components.Routing;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Volo.Abp.UI.Navigation; using Volo.Abp.UI.Navigation;
namespace Volo.Abp.AspNetCore.Components.Server.BasicTheme.Themes.Basic; namespace Volo.Abp.AspNetCore.Components.Server.BasicTheme.Themes.Basic;
@ -11,6 +13,9 @@ public partial class LoginDisplay : IDisposable
[Inject] [Inject]
protected IMenuManager MenuManager { get; set; } protected IMenuManager MenuManager { get; set; }
[Inject]
protected IHttpContextAccessor HttpContextAccessor { get; set; }
protected ApplicationMenu Menu { get; set; } protected ApplicationMenu Menu { get; set; }
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
@ -20,6 +25,12 @@ public partial class LoginDisplay : IDisposable
Navigation.LocationChanged += OnLocationChanged; 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) protected virtual void OnLocationChanged(object sender, LocationChangedEventArgs e)
{ {
InvokeAsync(StateHasChanged); 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.Globalization
@using System.Collections.Immutable @using System.Collections.Immutable
@using Volo.Abp.AspNetCore.Components.Web @using Volo.Abp.AspNetCore.Components.Web
@using Volo.Abp.AspNetCore.Mvc.Client
@inject ILanguageProvider LanguageProvider @inject ILanguageProvider LanguageProvider
@inject IJSRuntime JsRuntime @inject IJSRuntime JsRuntime
@inject ICookieService CookieService @inject ICookieService CookieService
@inject NavigationManager NavigationManager
@inject ICachedApplicationConfigurationClient ConfigurationClient
@if (_otherLanguages != null && _otherLanguages.Any()) @if (_otherLanguages != null && _otherLanguages.Any())
{ {
<BarDropdown RightAligned="true"> <BarDropdown RightAligned="true">
@ -22,9 +25,13 @@
@code { @code {
private IReadOnlyList<LanguageInfo> _otherLanguages; private IReadOnlyList<LanguageInfo> _otherLanguages;
private LanguageInfo _currentLanguage; private LanguageInfo _currentLanguage;
private bool _useRouteBasedCulture;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
var config = await ConfigurationClient.GetAsync();
_useRouteBasedCulture = config.Localization.UseRouteBasedCulture;
var selectedLanguageName = await JsRuntime.InvokeAsync<string>( var selectedLanguageName = await JsRuntime.InvokeAsync<string>(
"localStorage.getItem", "localStorage.getItem",
"Abp.SelectedLanguage" "Abp.SelectedLanguage"
@ -57,6 +64,24 @@
private async Task ChangeLanguageAsync(LanguageInfo language) 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( await JsRuntime.InvokeVoidAsync(
"localStorage.setItem", "localStorage.setItem",
"Abp.SelectedLanguage", "Abp.SelectedLanguage",

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

@ -35,6 +35,6 @@
</Dropdown> </Dropdown>
</Authorized> </Authorized>
<NotAuthorized> <NotAuthorized>
<a class="nav-link" href="@AuthenticationOptions.Value.LoginUrl">@UiLocalizer["Login"]</a> <a class="nav-link" href="@LoginUrl">@UiLocalizer["Login"]</a>
</NotAuthorized> </NotAuthorized>
</AuthorizeView> </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;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Routing; using Microsoft.AspNetCore.Components.Routing;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication; using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
using Microsoft.JSInterop; using Microsoft.JSInterop;
using Volo.Abp.AspNetCore.Components.Web.Security; using Volo.Abp.AspNetCore.Components.Web.Security;
using Volo.Abp.AspNetCore.Mvc.ApplicationConfigurations;
using Volo.Abp.AspNetCore.Mvc.Client;
using Volo.Abp.UI.Navigation; using Volo.Abp.UI.Navigation;
namespace Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme.Themes.Basic; namespace Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme.Themes.Basic;
@ -17,10 +21,35 @@ public partial class LoginDisplay : IDisposable
[Inject] [Inject]
protected ApplicationConfigurationChangedService ApplicationConfigurationChangedService { get; set; } protected ApplicationConfigurationChangedService ApplicationConfigurationChangedService { get; set; }
[Inject]
protected ICachedApplicationConfigurationClient ConfigurationClient { get; set; }
protected ApplicationMenu Menu { 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() protected async override Task OnInitializedAsync()
{ {
_config = await ConfigurationClient.GetAsync();
Menu = await MenuManager.GetAsync(StandardMenus.User); Menu = await MenuManager.GetAsync(StandardMenus.User);
Navigation.LocationChanged += OnLocationChanged; Navigation.LocationChanged += OnLocationChanged;
@ -53,7 +82,7 @@ public partial class LoginDisplay : IDisposable
} }
else else
{ {
Navigation.NavigateTo(uri); Navigation.NavigateTo(uri?.TrimStart('~', '/') ?? uri);
} }
} }

Loading…
Cancel
Save