From 0880b23d7cc231e86d4d2ae76068ddaff807a033 Mon Sep 17 00:00:00 2001 From: maliming Date: Mon, 30 Mar 2026 15:09:33 +0800 Subject: [PATCH] feat: Implement URL-based localization support for Blazor components and enhance menu item URL handling --- .../2026-03-29-Url-Based-Localization/POST.md | 44 +++++++---- .../fundamentals/url-based-localization.md | 35 ++++----- .../AbpWasmCultureMenuItemUrlProvider.cs | 75 +++++++++++++++++++ ...ApplicationLocalizationConfigurationDto.cs | 2 + .../AspNetCore/Mvc/AbpAspNetCoreMvcModule.cs | 1 - .../AbpApplicationConfigurationAppService.cs | 5 ++ ...NetCoreMvcQueryStringCultureReplacement.cs | 23 +++--- .../AbpCultureMenuItemUrlProvider.cs | 25 ++++--- .../AbpCultureMenuItemUrlProvider_Tests.cs | 58 +++++++++++++- .../AbpAccountBlazorUserMenuContributor.cs | 2 +- .../Themes/Basic/LoginDisplay.razor | 2 +- .../Themes/Basic/LoginDisplay.razor.cs | 11 +++ .../Themes/Basic/LanguageSwitch.razor | 27 ++++++- .../Themes/Basic/LoginDisplay.razor | 2 +- .../Themes/Basic/LoginDisplay.razor.cs | 31 +++++++- 15 files changed, 279 insertions(+), 64 deletions(-) create mode 100644 framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/AbpWasmCultureMenuItemUrlProvider.cs diff --git a/docs/en/Community-Articles/2026-03-29-Url-Based-Localization/POST.md b/docs/en/Community-Articles/2026-03-29-Url-Based-Localization/POST.md index afbc8461ed..9cbdaf0245 100644 --- a/docs/en/Community-Articles/2026-03-29-Url-Based-Localization/POST.md +++ b/docs/en/Community-Articles/2026-03-29-Url-Based-Localization/POST.md @@ -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(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) diff --git a/docs/en/framework/fundamentals/url-based-localization.md b/docs/en/framework/fundamentals/url-based-localization.md index ec28aeccf0..df92472871 100644 --- a/docs/en/framework/fundamentals/url-based-localization.md +++ b/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(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. diff --git a/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/AbpWasmCultureMenuItemUrlProvider.cs b/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/AbpWasmCultureMenuItemUrlProvider.cs new file mode 100644 index 0000000000..a0c89ab3ee --- /dev/null +++ b/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; + +/// +/// Prepends the culture route prefix to menu item URLs in Blazor WebAssembly when route-based culture is enabled. +/// +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); + } + } +} diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc.Contracts/Volo/Abp/AspNetCore/Mvc/ApplicationConfigurations/ApplicationLocalizationConfigurationDto.cs b/framework/src/Volo.Abp.AspNetCore.Mvc.Contracts/Volo/Abp/AspNetCore/Mvc/ApplicationConfigurations/ApplicationLocalizationConfigurationDto.cs index 5ae2a8ed2d..1f3bbe96a3 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc.Contracts/Volo/Abp/AspNetCore/Mvc/ApplicationConfigurations/ApplicationLocalizationConfigurationDto.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc.Contracts/Volo/Abp/AspNetCore/Mvc/ApplicationConfigurations/ApplicationLocalizationConfigurationDto.cs @@ -34,6 +34,8 @@ public class ApplicationLocalizationConfigurationDto public Dictionary> LanguageFilesMap { get; set; } + public bool UseRouteBasedCulture { get; set; } + public ApplicationLocalizationConfigurationDto() { Values = new Dictionary>(); diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AbpAspNetCoreMvcModule.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AbpAspNetCoreMvcModule.cs index e0d8cc5d4d..643d3d4af6 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AbpAspNetCoreMvcModule.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AbpAspNetCoreMvcModule.cs @@ -248,7 +248,6 @@ public class AbpAspNetCoreMvcModule : AbpModule context.Services.TryAddSingleton(); context.Services.Replace(ServiceDescriptor.Singleton()); - context.Services.AddTransient(); } public override void OnApplicationInitialization(ApplicationInitializationContext context) diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApplicationConfigurations/AbpApplicationConfigurationAppService.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApplicationConfigurations/AbpApplicationConfigurationAppService.cs index b1ae63c39b..f22b7739a2 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApplicationConfigurations/AbpApplicationConfigurationAppService.cs +++ b/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 localizationOptions, + IOptions requestLocalizationOptions, IOptions 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; } diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpAspNetCoreMvcQueryStringCultureReplacement.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpAspNetCoreMvcQueryStringCultureReplacement.cs index b8b4e6a8ea..da48686c03 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpAspNetCoreMvcQueryStringCultureReplacement.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpAspNetCoreMvcQueryStringCultureReplacement.cs @@ -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; diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureMenuItemUrlProvider.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureMenuItemUrlProvider.cs index cd069f7c04..0618bbf852 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureMenuItemUrlProvider.cs +++ b/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; /// -/// Prepends the culture route prefix to all local menu item URLs -/// when the current request has a {culture} route value. -/// Only activates when is true. +/// Prepends the culture route prefix to menu item URLs when route-based culture is enabled. /// -public class AbpCultureMenuItemUrlProvider : IMenuItemUrlProvider +public class AbpCultureMenuItemUrlProvider : IMenuItemUrlProvider, ITransientDependency { protected IHttpContextAccessor HttpContextAccessor { get; } protected IOptions 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); diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureMenuItemUrlProvider_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureMenuItemUrlProvider_Tests.cs index cd8296a352..f2543f336a 100644 --- a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureMenuItemUrlProvider_Tests.cs +++ b/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 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() { diff --git a/modules/account/src/Volo.Abp.Account.Blazor/AbpAccountBlazorUserMenuContributor.cs b/modules/account/src/Volo.Abp.Account.Blazor/AbpAccountBlazorUserMenuContributor.cs index 3fae85c5b5..d40170ffec 100644 --- a/modules/account/src/Volo.Abp.Account.Blazor/AbpAccountBlazorUserMenuContributor.cs +++ b/modules/account/src/Volo.Abp.Account.Blazor/AbpAccountBlazorUserMenuContributor.cs @@ -15,7 +15,7 @@ public class AbpAccountBlazorUserMenuContributor : IMenuContributor var accountResource = context.GetLocalizer(); - 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; } diff --git a/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.Server.BasicTheme/Themes/Basic/LoginDisplay.razor b/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.Server.BasicTheme/Themes/Basic/LoginDisplay.razor index 973912af30..fe01c0d5bb 100644 --- a/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.Server.BasicTheme/Themes/Basic/LoginDisplay.razor +++ b/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.Server.BasicTheme/Themes/Basic/LoginDisplay.razor @@ -33,6 +33,6 @@ - @L["Login"] + @L["Login"] diff --git a/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.Server.BasicTheme/Themes/Basic/LoginDisplay.razor.cs b/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.Server.BasicTheme/Themes/Basic/LoginDisplay.razor.cs index 84696d7f29..f2bb705391 100644 --- a/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.Server.BasicTheme/Themes/Basic/LoginDisplay.razor.cs +++ b/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); diff --git a/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/Themes/Basic/LanguageSwitch.razor b/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/Themes/Basic/LanguageSwitch.razor index 7814000136..266a943261 100644 --- a/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/Themes/Basic/LanguageSwitch.razor +++ b/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()) { @@ -22,9 +25,13 @@ @code { private IReadOnlyList _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( "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", diff --git a/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/Themes/Basic/LoginDisplay.razor b/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/Themes/Basic/LoginDisplay.razor index 6c02e4ffc0..b961dc6e6b 100644 --- a/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/Themes/Basic/LoginDisplay.razor +++ b/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/Themes/Basic/LoginDisplay.razor @@ -35,6 +35,6 @@ - @UiLocalizer["Login"] + @UiLocalizer["Login"] diff --git a/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/Themes/Basic/LoginDisplay.razor.cs b/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/Themes/Basic/LoginDisplay.razor.cs index 08d40e4bfe..5128141c47 100644 --- a/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/Themes/Basic/LoginDisplay.razor.cs +++ b/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); } }