Browse Source

feat: Enhance URL-based localization support for Blazor components and add tests

pull/25174/head
maliming 20 hours ago
parent
commit
6f15b2f71a
No known key found for this signature in database GPG Key ID: A646B9CB645ECEA4
  1. 84
      docs/en/Community-Articles/2026-03-29-Url-Based-Localization/POST.md
  2. 143
      docs/en/framework/fundamentals/url-based-localization.md
  3. 31
      framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureMenuItemUrlProvider.cs
  4. 2
      framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureRoutePagesConvention.cs
  5. 204
      framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureMenuItemUrlProvider_Tests.cs

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

@ -81,22 +81,104 @@ Reading from the request cookie (rather than `CultureInfo.CurrentCulture`) is im
No theme changes, no language switcher changes — the existing UI component just works.
## Blazor Server & Blazor WebAssembly (WebApp)
Blazor Server uses SignalR (WebSocket) for its interactive circuit. The HTTP middleware pipeline only runs on the **initial page load** — subsequent interactions happen over the WebSocket. ABP handles this by persisting the detected URL culture to a cookie on the first request, so the entire Blazor circuit uses the correct language.
Blazor WebAssembly (WebApp) is similar. The server renders the first page via SSR and detects the culture from the URL. After WASM downloads, subsequent renders run in the browser. The WASM app reads the culture from the server's `/api/abp/application-configuration` response, so the culture stays consistent.
### 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 unavailable), it falls back to `CultureInfo.CurrentUICulture`. |
| **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. |
### Important: Blazor component route limitation
In MVC / Razor Pages, ABP uses `AbpCultureRoutePagesConvention` (an `IPageRouteModelConvention`) to **automatically** add `{culture}/...` route selectors to every page at startup. No code changes needed.
**Blazor components do not have this capability.** ASP.NET Core does not provide an `IPageRouteModelConvention` equivalent for Blazor components. Blazor routes are compiled from `@page` directives into `[RouteAttribute]` at build time, with no runtime extension point. This is an [ASP.NET Core platform limitation](https://github.com/dotnet/aspnetcore/issues/57167), not an ABP limitation.
You must **manually** add the `{culture}` route to each of your own Blazor pages:
```razor
@page "/"
@page "/{culture}"
@code {
[Parameter]
public string? Culture { get; set; }
}
```
```razor
@page "/Products"
@page "/{culture}/Products"
@code {
[Parameter]
public string? Culture { get; set; }
}
```
> **ABP's built-in module pages** (Identity, Tenant Management, Settings, etc.) do **not** need this change. Language switching always uses `forceLoad: true`, which triggers a full HTTP request through the middleware pipeline. The `{culture}` route is only needed for direct URL access like `/zh-Hans/Products`.
### 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:
```csharp
// ABP BasicTheme LanguageSwitch.razor
NavigationManager.NavigateTo(
$"Abp/Languages/Switch?culture={language.CultureName}&uiCulture={language.UiCultureName}&returnUrl={relativeUrl}",
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
PreConfigure<AbpAspNetCoreComponentsWebOptions>(options =>
{
options.IsBlazorWebApp = true;
});
Configure<AbpRequestLocalizationOptions>(options =>
{
options.UseRouteBasedCulture = true;
});
```
## 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.
## 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 |
## Summary
To add SEO-friendly localized URL paths to your ABP application:
1. Set `options.UseRouteBasedCulture = true` in your module.
2. Ensure `UseAbpRequestLocalization()` comes after `UseRouting()` in the pipeline.
3. For **Blazor** projects, add `@page "/{culture}/..."` routes to your own pages.
ABP automatically registers the culture route, adds `{culture}/...` selectors to all Razor Pages, rewrites URLs generated by `Url.Page()` and `Url.Action()`, updates navigation menus, and handles language switching — all without any changes to themes, menu contributors, or views.
A runnable sample demonstrating this feature is available at [abp-samples/UrlBasedLocalization](https://github.com/abpframework/abp-samples/tree/master/UrlBasedLocalization). It includes English, Turkish, French, and Simplified Chinese, and is the quickest way to see the feature in action before integrating it into your own project.
A runnable sample demonstrating this feature is available at [abp-samples/UrlBasedLocalization](https://github.com/abpframework/abp-samples/tree/master/UrlBasedLocalization). It includes MVC, Blazor Server, and Blazor WebApp projects with English, Turkish, French, and Simplified Chinese.
## References

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

@ -79,17 +79,142 @@ ABP's built-in language switcher (the `/Abp/Languages/Switch` action) automatica
No changes are needed in any theme or language switcher component.
## MVC / Razor Pages
MVC and Razor Pages have the most complete support. Everything works automatically when `UseRouteBasedCulture = true`:
| Feature | How it works |
|---|---|
| **Route registration** | `AbpCultureRoutePagesConvention` adds `{culture}/...` route selectors to every Razor Page. MVC controllers get a `{culture}/{controller}/{action}` conventional route. |
| **URL generation** | `AbpCultureRouteUrlHelperFactory` wraps `IUrlHelperFactory` to auto-inject the `{culture}` route value into `Url.Page()`, `Url.Action()`, and tag helpers. |
| **Menu URLs** | `AbpCultureMenuItemUrlProvider` prepends the culture prefix to all local menu item URLs. |
| **Language switching** | The `/Abp/Languages/Switch` action replaces the culture segment in the return URL and sets the cookie. |
**No code changes are needed in your pages or controllers.** The framework handles everything.
### Example middleware pipeline
````csharp
app.UseRouting();
app.UseAbpRequestLocalization();
app.UseAuthorization();
app.UseConfiguredEndpoints();
````
## Blazor Server
Blazor Server uses SignalR (WebSocket), which does not re-run the HTTP middleware pipeline after the initial connection. ABP automatically persists the detected URL culture to a **Cookie** on the first request, so the entire Blazor circuit uses the correct language.
Blazor Server uses SignalR (WebSocket) for the interactive circuit. The HTTP middleware pipeline only runs on the **initial page load** — subsequent interactions happen over the WebSocket connection. ABP handles this by persisting the detected URL culture to a **Cookie** on the first request, so the entire Blazor circuit uses the correct language.
### 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 automatically saves the detected culture to `.AspNetCore.Culture` cookie, which persists across the WebSocket connection. |
| **Menu URLs** | `AbpCultureMenuItemUrlProvider` prepends the culture prefix. In the interactive circuit (where `HttpContext` has no route values), it falls back to `CultureInfo.CurrentUICulture`. |
| **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. |
### What requires manual changes
**Blazor component routes**: ASP.NET Core does not provide an `IPageRouteModelConvention` equivalent for Blazor components. You must manually add the `{culture}` route to each page:
````razor
@page "/"
@page "/{culture}"
@code {
[Parameter]
public string? Culture { get; set; }
}
````
````razor
@page "/About"
@page "/{culture}/About"
@code {
[Parameter]
public string? Culture { get; set; }
}
````
> This applies to your own application pages. ABP built-in module pages (Identity, Tenant Management, etc.) do not need this change because language switching uses `forceLoad: true`, which always triggers a full HTTP request through the middleware pipeline.
### Example module configuration
````csharp
PreConfigure<AbpAspNetCoreComponentsWebOptions>(options =>
{
options.IsBlazorWebApp = true;
});
Configure<AbpRequestLocalizationOptions>(options =>
{
options.UseRouteBasedCulture = true;
});
````
### Example middleware pipeline
````csharp
app.UseRouting();
app.UseAbpRequestLocalization();
app.UseAntiforgery();
app.UseConfiguredEndpoints(builder =>
{
builder.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
});
````
## Blazor WebAssembly (WebApp)
Blazor WebAssembly (WASM) runs in the browser. On the **first page load**, the server renders the page via SSR, and the culture is detected from the URL. After WASM downloads, subsequent renders run in the browser. The WASM app fetches `/api/abp/application-configuration` from the server to get the current culture, so the culture stays consistent.
No additional configuration is needed beyond `UseRouteBasedCulture = true` and the correct middleware order.
### What works automatically
## Blazor WebAssembly
| Feature | How it works |
|---|---|
| **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. |
The server project handles culture detection via routing. The WebAssembly client reads the culture from the server's application configuration API, which already reflects the URL-based culture.
### What requires manual changes
No code changes are required in the WASM project.
Same as Blazor Server — you must manually add `@page "/{culture}/..."` routes to your Blazor pages.
### Example module configuration
````csharp
// Server project
PreConfigure<AbpAspNetCoreComponentsWebOptions>(options =>
{
options.IsBlazorWebApp = true;
});
Configure<AbpRequestLocalizationOptions>(options =>
{
options.UseRouteBasedCulture = true;
});
// WASM client project — no special configuration needed
````
### Example middleware pipeline
````csharp
app.UseRouting();
app.UseAbpRequestLocalization();
app.UseAntiforgery();
app.UseConfiguredEndpoints(builder =>
{
builder.MapRazorComponents<App>()
.AddInteractiveServerRenderMode()
.AddInteractiveWebAssemblyRenderMode()
.AddAdditionalAssemblies(clientAssembly);
});
````
## Multi-Tenancy Compatibility
@ -119,3 +244,11 @@ Yes. All providers work together in priority order:
### 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?
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.
### Do I need to add `{culture}` routes to ABP module pages (Identity, Settings, etc.)?
No. Language switching in Blazor always uses `forceLoad: true`, which triggers a full HTTP request. The server-side middleware detects the culture from the URL path and sets the cookie. The interactive circuit then uses the cookie-based culture. Module pages work correctly without any changes.

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

@ -1,8 +1,12 @@
using System;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.RequestLocalization;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Options;
using Volo.Abp.Localization;
using Volo.Abp.UI.Navigation;
namespace Volo.Abp.AspNetCore.Mvc.Localization;
@ -16,13 +20,16 @@ public class AbpCultureMenuItemUrlProvider : IMenuItemUrlProvider
{
protected IHttpContextAccessor HttpContextAccessor { get; }
protected IOptions<AbpRequestLocalizationOptions> LocalizationOptions { get; }
protected IOptions<AbpLocalizationOptions> AbpLocalizationOptions { get; }
public AbpCultureMenuItemUrlProvider(
IHttpContextAccessor httpContextAccessor,
IOptions<AbpRequestLocalizationOptions> localizationOptions)
IOptions<AbpRequestLocalizationOptions> localizationOptions,
IOptions<AbpLocalizationOptions> abpLocalizationOptions)
{
HttpContextAccessor = httpContextAccessor;
LocalizationOptions = localizationOptions;
AbpLocalizationOptions = abpLocalizationOptions;
}
public virtual Task HandleAsync(MenuItemUrlProviderContext context)
@ -32,7 +39,7 @@ public class AbpCultureMenuItemUrlProvider : IMenuItemUrlProvider
return Task.CompletedTask;
}
var culture = HttpContextAccessor.HttpContext?.GetRouteValue("culture")?.ToString();
var culture = GetCulture();
if (string.IsNullOrEmpty(culture))
{
return Task.CompletedTask;
@ -44,6 +51,26 @@ public class AbpCultureMenuItemUrlProvider : IMenuItemUrlProvider
return Task.CompletedTask;
}
protected virtual string? GetCulture()
{
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.CurrentUICulture
// which was set by the middleware during SSR and persisted in the circuit.
var currentCulture = CultureInfo.CurrentUICulture.Name;
var isKnownCulture = AbpLocalizationOptions.Value.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)

2
framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureRoutePagesConvention.cs

@ -17,7 +17,7 @@ public class AbpCultureRoutePagesConvention : IPageRouteModelConvention
/// </summary>
internal const string CultureRouteTemplate = "{culture:regex(^[a-zA-Z]{{2,8}}(-[a-zA-Z0-9]{{1,8}})*$)}";
public void Apply(PageRouteModel model)
public virtual void Apply(PageRouteModel model)
{
var selectorsToAdd = new List<SelectorModel>();

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

@ -0,0 +1,204 @@
using System.Globalization;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.RequestLocalization;
using Microsoft.AspNetCore.Routing;
using Shouldly;
using MsOptions = Microsoft.Extensions.Options.Options;
using Volo.Abp.Localization;
using Volo.Abp.UI.Navigation;
using Xunit;
namespace Volo.Abp.AspNetCore.Mvc.Localization;
public class AbpCultureMenuItemUrlProvider_Tests
{
[Fact]
public async Task Should_Not_Modify_Urls_When_RouteBasedCulture_Is_Disabled()
{
var provider = CreateProvider(useRouteBasedCulture: false, cultureName: "zh-Hans");
var menu = CreateMenuWithItems("/home", "/about");
await provider.HandleAsync(new MenuItemUrlProviderContext(menu));
menu.Items[0].Url.ShouldBe("/home");
menu.Items[1].Url.ShouldBe("/about");
}
[Fact]
public async Task Should_Prepend_Culture_Prefix_When_Route_Has_Culture()
{
var provider = CreateProvider(useRouteBasedCulture: true, cultureName: "zh-Hans");
var menu = CreateMenuWithItems("/home", "/about");
await provider.HandleAsync(new MenuItemUrlProviderContext(menu));
menu.Items[0].Url.ShouldBe("/zh-Hans/home");
menu.Items[1].Url.ShouldBe("/zh-Hans/about");
}
[Fact]
public async Task Should_Not_Add_Prefix_When_No_Culture_Route_Value()
{
// MVC request with no {culture} route value (e.g. user visits /About directly)
var provider = CreateProvider(useRouteBasedCulture: true, cultureName: null);
var menu = CreateMenuWithItems("/home", "/about");
await provider.HandleAsync(new MenuItemUrlProviderContext(menu));
menu.Items[0].Url.ShouldBe("/home");
menu.Items[1].Url.ShouldBe("/about");
}
[Fact]
public async Task Should_Use_CurrentUICulture_Fallback_When_No_HttpContext()
{
// Simulates Blazor interactive circuit: no HttpContext, but CurrentUICulture is set
var provider = CreateProviderWithoutHttpContext(
useRouteBasedCulture: true,
knownLanguages: new[] { "en", "zh-Hans", "tr" });
var menu = CreateMenuWithItems("/home", "/about");
var previousCulture = CultureInfo.CurrentUICulture;
try
{
CultureInfo.CurrentUICulture = new CultureInfo("zh-Hans");
await provider.HandleAsync(new MenuItemUrlProviderContext(menu));
}
finally
{
CultureInfo.CurrentUICulture = previousCulture;
}
menu.Items[0].Url.ShouldBe("/zh-Hans/home");
menu.Items[1].Url.ShouldBe("/zh-Hans/about");
}
[Fact]
public async Task Should_Not_Modify_Urls_When_No_HttpContext_And_Unknown_Culture()
{
// Blazor interactive circuit with a culture that is not in the known languages list
var provider = CreateProviderWithoutHttpContext(
useRouteBasedCulture: true,
knownLanguages: new[] { "en", "tr" });
var menu = CreateMenuWithItems("/home", "/about");
var previousCulture = CultureInfo.CurrentUICulture;
try
{
CultureInfo.CurrentUICulture = new CultureInfo("fr");
await provider.HandleAsync(new MenuItemUrlProviderContext(menu));
}
finally
{
CultureInfo.CurrentUICulture = previousCulture;
}
menu.Items[0].Url.ShouldBe("/home");
menu.Items[1].Url.ShouldBe("/about");
}
[Fact]
public async Task Should_Prepend_Prefix_Recursively_For_Nested_Items()
{
var provider = CreateProvider(useRouteBasedCulture: true, cultureName: "tr");
var menu = new ApplicationMenu("TestMenu");
var parent = new ApplicationMenuItem("Parent", "Parent", url: "/parent");
var child = new ApplicationMenuItem("Child", "Child", url: "/child");
var grandChild = new ApplicationMenuItem("GrandChild", "GrandChild", url: "/grandchild");
child.AddItem(grandChild);
parent.AddItem(child);
menu.AddItem(parent);
await provider.HandleAsync(new MenuItemUrlProviderContext(menu));
parent.Url.ShouldBe("/tr/parent");
child.Url.ShouldBe("/tr/child");
grandChild.Url.ShouldBe("/tr/grandchild");
}
[Fact]
public async Task Should_Not_Modify_External_Urls()
{
var provider = CreateProvider(useRouteBasedCulture: true, cultureName: "zh-Hans");
var menu = new ApplicationMenu("TestMenu");
menu.AddItem(new ApplicationMenuItem("External", "External", url: "https://example.com/page"));
menu.AddItem(new ApplicationMenuItem("Relative", "Relative", url: "page"));
menu.AddItem(new ApplicationMenuItem("Local", "Local", url: "/local"));
await provider.HandleAsync(new MenuItemUrlProviderContext(menu));
// External and relative URLs should not be modified
menu.Items[0].Url.ShouldBe("https://example.com/page");
menu.Items[1].Url.ShouldBe("page");
// Local URL should be prefixed
menu.Items[2].Url.ShouldBe("/zh-Hans/local");
}
[Fact]
public async Task Should_Not_Throw_When_Url_Is_Null()
{
var provider = CreateProvider(useRouteBasedCulture: true, cultureName: "tr");
var menu = new ApplicationMenu("TestMenu");
menu.AddItem(new ApplicationMenuItem("NoUrl", "No URL", url: null));
menu.AddItem(new ApplicationMenuItem("WithUrl", "With URL", url: "/page"));
await provider.HandleAsync(new MenuItemUrlProviderContext(menu));
// Null URL should remain null
menu.Items[0].Url.ShouldBeNull();
// Normal URL should be prefixed
menu.Items[1].Url.ShouldBe("/tr/page");
}
private static AbpCultureMenuItemUrlProvider CreateProvider(
bool useRouteBasedCulture,
string? cultureName)
{
var httpContext = new DefaultHttpContext();
if (cultureName != null)
{
httpContext.Request.RouteValues["culture"] = cultureName;
}
var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext };
var localizationOptions = MsOptions.Create(
new AbpRequestLocalizationOptions { UseRouteBasedCulture = useRouteBasedCulture });
var abpLocalizationOptions = MsOptions.Create(new AbpLocalizationOptions());
return new AbpCultureMenuItemUrlProvider(
httpContextAccessor, localizationOptions, abpLocalizationOptions);
}
private static AbpCultureMenuItemUrlProvider CreateProviderWithoutHttpContext(
bool useRouteBasedCulture,
string[] knownLanguages)
{
var httpContextAccessor = new HttpContextAccessor { HttpContext = null };
var localizationOptions = MsOptions.Create(
new AbpRequestLocalizationOptions { UseRouteBasedCulture = useRouteBasedCulture });
var abpLocOptions = new AbpLocalizationOptions();
foreach (var lang in knownLanguages)
{
abpLocOptions.Languages.Add(new LanguageInfo(lang));
}
return new AbpCultureMenuItemUrlProvider(
httpContextAccessor, localizationOptions, MsOptions.Create(abpLocOptions));
}
private static ApplicationMenu CreateMenuWithItems(params string[] urls)
{
var menu = new ApplicationMenu("TestMenu");
for (var i = 0; i < urls.Length; i++)
{
menu.AddItem(new ApplicationMenuItem($"Item{i}", $"Item {i}", url: urls[i]));
}
return menu;
}
}
Loading…
Cancel
Save