Browse Source

Merge pull request #25174 from abpframework/feature/url-based-localization

feat: Implement route-based culture support in localization.
pull/25214/head
Engincan VESKE 2 months ago
committed by GitHub
parent
commit
7b7b07e64f
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 167
      docs/en/Community-Articles/2026-03-29-Url-Based-Localization/POST.md
  2. BIN
      docs/en/Community-Articles/2026-03-29-Url-Based-Localization/cover.png
  3. BIN
      docs/en/Community-Articles/2026-03-29-Url-Based-Localization/images/blazor-server-zh-hans.png
  4. BIN
      docs/en/Community-Articles/2026-03-29-Url-Based-Localization/images/blazor-webapp-tr.png
  5. BIN
      docs/en/Community-Articles/2026-03-29-Url-Based-Localization/images/module-identity-users.png
  6. BIN
      docs/en/Community-Articles/2026-03-29-Url-Based-Localization/images/mvc-home-en.png
  7. BIN
      docs/en/Community-Articles/2026-03-29-Url-Based-Localization/images/mvc-home-tr.png
  8. 1
      docs/en/framework/fundamentals/index.md
  9. 4
      docs/en/framework/fundamentals/localization.md
  10. 173
      docs/en/framework/fundamentals/url-based-localization.md
  11. BIN
      docs/en/images/url-based-localization-angular-menu-url.png
  12. BIN
      docs/en/images/url-based-localization-angular-routes.png
  13. 63
      framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/AbpWasmCultureMenuItemUrlProvider.cs
  14. 25
      framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/CultureAwareAuthenticationBase.cs
  15. 35
      framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/CultureAwareRedirectToLoginHelper.cs
  16. 4
      framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/Volo.Abp.AspNetCore.Components.WebAssembly.Theming.csproj
  17. 14
      framework/src/Volo.Abp.AspNetCore.Components.WebAssembly/Volo/Abp/AspNetCore/Components/WebAssembly/IRouteBasedCultureNavigationHelper.cs
  18. 14
      framework/src/Volo.Abp.AspNetCore.Components.WebAssembly/Volo/Abp/AspNetCore/Components/WebAssembly/IRouteBasedCultureUrlHelper.cs
  19. 56
      framework/src/Volo.Abp.AspNetCore.Components.WebAssembly/Volo/Abp/AspNetCore/Components/WebAssembly/RouteBasedCultureNavigationHelper.cs
  20. 90
      framework/src/Volo.Abp.AspNetCore.Components.WebAssembly/Volo/Abp/AspNetCore/Components/WebAssembly/RouteBasedCultureUrlHelper.cs
  21. 2
      framework/src/Volo.Abp.AspNetCore.Mvc.Contracts/Volo/Abp/AspNetCore/Mvc/ApplicationConfigurations/ApplicationLocalizationConfigurationDto.cs
  22. 41
      framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AbpAspNetCoreMvcModule.cs
  23. 5
      framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApplicationConfigurations/AbpApplicationConfigurationAppService.cs
  24. 84
      framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpAspNetCoreMvcQueryStringCultureReplacement.cs
  25. 71
      framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureAwareUrlHelper.cs
  26. 69
      framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureMenuItemUrlProvider.cs
  27. 37
      framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureRouteConstraint.cs
  28. 47
      framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureRoutePagesConvention.cs
  29. 49
      framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureRouteUrlHelperFactory.cs
  30. 24
      framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpLanguagesController.cs
  31. 11
      framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/QueryStringCultureReplacementContext.cs
  32. 47
      framework/src/Volo.Abp.AspNetCore/Microsoft/AspNetCore/RequestLocalization/AbpRequestCultureCookieHelper.cs
  33. 21
      framework/src/Volo.Abp.AspNetCore/Microsoft/AspNetCore/RequestLocalization/AbpRequestLocalizationMiddleware.cs
  34. 6
      framework/src/Volo.Abp.AspNetCore/Microsoft/AspNetCore/RequestLocalization/AbpRequestLocalizationOptions.cs
  35. 16
      framework/src/Volo.Abp.AspNetCore/Microsoft/AspNetCore/RequestLocalization/DefaultAbpRequestLocalizationOptionsProvider.cs
  36. 8
      framework/src/Volo.Abp.UI.Navigation/Volo/Abp/Ui/Navigation/IMenuItemCulturePrefixHelper.cs
  37. 13
      framework/src/Volo.Abp.UI.Navigation/Volo/Abp/Ui/Navigation/IMenuItemUrlProvider.cs
  38. 33
      framework/src/Volo.Abp.UI.Navigation/Volo/Abp/Ui/Navigation/MenuItemCulturePrefixHelper.cs
  39. 11
      framework/src/Volo.Abp.UI.Navigation/Volo/Abp/Ui/Navigation/MenuItemUrlProviderContext.cs
  40. 6
      framework/src/Volo.Abp.UI.Navigation/Volo/Abp/Ui/Navigation/MenuManager.cs
  41. 1
      framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo.Abp.AspNetCore.Mvc.Tests.csproj
  42. 6
      framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/AbpAspNetCoreMvcTestModule.cs
  43. 137
      framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureAwareUrlHelper_Tests.cs
  44. 280
      framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureMenuItemUrlProvider_Tests.cs
  45. 123
      framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/AbpLanguagesController_Tests.cs
  46. 259
      framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/LanguageSwitchRouteCultureReplacement_Tests.cs
  47. 48
      framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/RouteBasedCultureApiRouting_Tests.cs
  48. 103
      framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/RouteBasedCultureNavigationHelper_Tests.cs
  49. 156
      framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/RouteBasedCultureUrlHelper_Tests.cs
  50. 53
      framework/test/Volo.Abp.AspNetCore.Tests/Volo/Abp/AspNetCore/Localization/RouteBasedCultureTestModule.cs
  51. 75
      framework/test/Volo.Abp.AspNetCore.Tests/Volo/Abp/AspNetCore/Localization/RouteBasedCulture_Tests.cs
  52. 2
      modules/account/src/Volo.Abp.Account.Blazor/AbpAccountBlazorUserMenuContributor.cs
  53. 1
      modules/account/src/Volo.Abp.Account.Blazor/Pages/Account/AccountManage.razor
  54. 3
      modules/account/src/Volo.Abp.Account.Blazor/Pages/Account/AccountManage.razor.cs
  55. 2
      modules/basic-theme/src/Volo.Abp.AspNetCore.Components.Server.BasicTheme/Themes/Basic/LoginDisplay.razor
  56. 11
      modules/basic-theme/src/Volo.Abp.AspNetCore.Components.Server.BasicTheme/Themes/Basic/LoginDisplay.razor.cs
  57. 4
      modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/BasicThemeToolbarContributor.cs
  58. 18
      modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/Themes/Basic/LanguageSwitch.razor
  59. 2
      modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/Themes/Basic/LoginDisplay.razor
  60. 25
      modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/Themes/Basic/LoginDisplay.razor.cs
  61. 21
      modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/Themes/Basic/WebAssemblyRedirectToLogin.razor
  62. 1
      modules/identity/src/Volo.Abp.Identity.Blazor/Pages/Identity/RoleManagement.razor
  63. 4
      modules/identity/src/Volo.Abp.Identity.Blazor/Pages/Identity/RoleManagement.razor.cs
  64. 1
      modules/identity/src/Volo.Abp.Identity.Blazor/Pages/Identity/UserManagement.razor
  65. 3
      modules/identity/src/Volo.Abp.Identity.Blazor/Pages/Identity/UserManagement.razor.cs
  66. 1
      modules/setting-management/src/Volo.Abp.SettingManagement.Blazor/Pages/SettingManagement/SettingManagement.razor
  67. 3
      modules/setting-management/src/Volo.Abp.SettingManagement.Blazor/Pages/SettingManagement/SettingManagement.razor.cs
  68. 1
      modules/tenant-management/src/Volo.Abp.TenantManagement.Blazor/Pages/TenantManagement/TenantManagement.razor
  69. 4
      modules/tenant-management/src/Volo.Abp.TenantManagement.Blazor/Pages/TenantManagement/TenantManagement.razor.cs
  70. 6
      npm/ng-packs/packages/core/src/lib/components/dynamic-layout.component.ts
  71. 19
      npm/ng-packs/packages/core/src/lib/guards/permission.guard.ts
  72. 1
      npm/ng-packs/packages/core/src/lib/pipes/index.ts
  73. 18
      npm/ng-packs/packages/core/src/lib/pipes/route-culture-url.pipe.ts
  74. 1
      npm/ng-packs/packages/core/src/lib/proxy/volo/abp/asp-net-core/mvc/application-configurations/models.ts
  75. 2
      npm/ng-packs/packages/core/src/lib/services/index.ts
  76. 170
      npm/ng-packs/packages/core/src/lib/services/route-based-culture-url.service.ts
  77. 59
      npm/ng-packs/packages/core/src/lib/services/route-based-culture.service.ts
  78. 4
      npm/ng-packs/packages/core/src/lib/services/title-strategy.service.ts
  79. 81
      npm/ng-packs/packages/core/src/lib/tests/route-based-culture-url.service.spec.ts
  80. 81
      npm/ng-packs/packages/core/src/lib/tests/route-based-culture.utils.spec.ts
  81. 44
      npm/ng-packs/packages/core/src/lib/tests/route-culture-url-matcher.spec.ts
  82. 2
      npm/ng-packs/packages/core/src/lib/utils/index.ts
  83. 6
      npm/ng-packs/packages/core/src/lib/utils/initial-utils.ts
  84. 82
      npm/ng-packs/packages/core/src/lib/utils/route-based-culture.utils.ts
  85. 59
      npm/ng-packs/packages/core/src/lib/utils/route-culture-url-matcher.ts
  86. 4
      npm/ng-packs/packages/theme-basic/src/lib/components/routes/routes.component.html
  87. 2
      npm/ng-packs/packages/theme-basic/src/lib/components/routes/routes.component.ts
  88. 2
      npm/ng-packs/packages/theme-shared/src/lib/components/breadcrumb-items/breadcrumb-items.component.html
  89. 4
      npm/ng-packs/packages/theme-shared/src/lib/components/breadcrumb-items/breadcrumb-items.component.ts
  90. 7
      npm/ng-packs/packages/theme-shared/src/lib/components/breadcrumb/breadcrumb.component.ts

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

@ -0,0 +1,167 @@
# SEO-Friendly Localized URLs in ABP with a Single Line of Configuration
ABP has always supported language switching via the `?culture=en` query string and the culture cookie. That works fine for most applications — but it has a limitation that shows up quickly once SEO or link-sharing matters.
Consider a book-store app where users browse in their language:
- A Spanish user shares a product link. The recipient opens it in English because the cookie on *their* machine says `en`.
- Search engines crawl the same URL in every language, making it impossible to create separate sitemaps per locale.
- A user shares a link like `/Books/Detail?id=42&culture=es`. When the server processes the request, it sets the culture cookie and then redirects to `/Books/Detail?id=42` — stripping the `?culture=` parameter. The shared link no longer carries the intended language.
Embedding the culture in the URL path — `/es/books`, `/zh-Hans/about` — solves all three. Each language has its own stable URL, readable by humans and index-friendly for search engines.
ABP supports this out of the box. You opt in with a single configuration property, and the framework takes care of routing, URL generation, menu links, and language switching automatically.
## Enabling URL-Based Localization
In your ABP module class, add:
```csharp
Configure<AbpRequestLocalizationOptions>(options =>
{
options.UseRouteBasedCulture = true;
});
```
That is the only change you need to make.
## MVC / Razor Pages
MVC and Razor Pages have the most complete support — everything works automatically. No code changes needed in your pages or controllers.
![MVC sample — English](images/mvc-home-en.png)
![MVC sample — Turkish](images/mvc-home-tr.png)
## What Happens Automatically
When you set `UseRouteBasedCulture = true`, ABP automatically:
- Registers ASP.NET Core's built-in [`RouteDataRequestCultureProvider`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.localization.routing.routedatarequestcultureprovider) to detect culture from the URL path.
- Adds a `{culture}/{controller}/{action}` conventional route for MVC controllers, with a route constraint to prevent non-culture URL segments (like `/enterprise/products`) from matching.
- Adds `{culture}/...` route selectors to all Razor Pages at startup.
- Injects the current culture into all `Url.Page()` and `Url.Action()` calls, so generated URLs automatically include the culture prefix.
- Prepends the culture prefix to navigation menu item URLs.
You do not need to configure these individually.
## URL Generation Just Works
In a Razor Page or view running under a culture-prefixed URL (say, `/zh-Hans/Books`), you do not need to pass a `culture` parameter anywhere:
```cshtml
@Url.Page("/Books/Detail", new { id = book.Id })
@* Generates: /zh-Hans/Books/Detail?id=42 *@
@Url.Action("About", "Home")
@* Generates: /zh-Hans/Home/About *@
```
If you explicitly pass a different `culture` value, that takes precedence — so cross-language links are also straightforward:
```cshtml
@Url.Page("/Books/Index", new { culture = "tr" })
@* Generates: /tr/Books *@
```
## Language Switching
The built-in ABP language switcher already works with route-based culture. When a user switches language, the culture segment in the URL is automatically replaced:
| Current URL | Switch to | Redirect to |
|---|---|---|
| `/tr/books` | `en` | `/en/books` |
| `/zh-Hans/about` | `en` | `/en/about` |
| `/tenant-a/zh-Hans/about` | `en` | `/tenant-a/en/about` |
No theme changes, no language switcher changes — the existing UI component just works.
## Blazor Support
Blazor Server and Blazor WebAssembly (WebApp) both support URL-based localization. Culture detection and cookie persistence work automatically on the initial page load (SSR). Menu URLs and language switching also work automatically.
![Blazor Server sample](images/blazor-server-zh-hans.png)
![Blazor WebApp sample](images/blazor-webapp-tr.png)
ABP's built-in module pages (Identity, Settings, etc.) also work with URL-based localization out of the box:
![Identity module — User Management](images/module-identity-users.png)
### Manual step: Blazor component routes
The only manual step for Blazor is adding `@page "/{culture}/..."` routes to your own pages. ASP.NET Core does not support automatically adding route selectors to Blazor components (unlike Razor Pages), so you must add them explicitly:
```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, Account, etc.) already ship with `@page "/{culture}/..."` route variants. You only need to add these routes to your own application pages.
### Blazor WebApp (WASM) configuration
The WASM client project does not need any `UseRouteBasedCulture` configuration. It reads the setting from the server automatically.
```csharp
// Server project — the only place you need to configure
Configure<AbpRequestLocalizationOptions>(options =>
{
options.UseRouteBasedCulture = true;
});
```
## Multi-Tenancy
URL-based localization is fully compatible with ABP's multi-tenant routing. Language switching supports tenant-prefixed URLs, so `/tenant-a/zh-Hans/About` correctly switches to `/tenant-a/en/About` without any additional configuration.
## 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 | Add `{culture}` route to pages |
| **Blazor WebApp (WASM)** | Manual `@page` routes | N/A | Automatic | Automatic | Add `{culture}` route to pages |
## Running the Sample
A runnable sample is available at [abp-samples/UrlBasedLocalization](https://github.com/abpframework/abp-samples/tree/master/UrlBasedLocalization), with three projects:
| Project | UI Type | URL | Command |
|---|---|---|---|
| `BookStore.Mvc` | MVC / Razor Pages | `https://localhost:44335` | `dotnet run --project src/BookStore.Mvc` |
| `BookStore.Blazor.Server` | Blazor Server | `https://localhost:44336` | `dotnet run --project src/BookStore.Blazor.Server` |
| `BookStore.Blazor.WebApp` | Blazor WebApp (InteractiveAuto) | `https://localhost:44337` | `dotnet run --project src/BookStore.Blazor.WebApp` |
Supported languages: English, Türkçe, Français, 简体中文.
## Summary
To add SEO-friendly localized URL paths to your ABP application:
1. Set `options.UseRouteBasedCulture = true` in your module.
2. For **Blazor** projects, add `@page "/{culture}/..."` routes to your own pages.
Everything else — route registration, URL generation, menu links, and language switching — is handled automatically.
## References
- [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)

BIN
docs/en/Community-Articles/2026-03-29-Url-Based-Localization/cover.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

BIN
docs/en/Community-Articles/2026-03-29-Url-Based-Localization/images/blazor-server-zh-hans.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

BIN
docs/en/Community-Articles/2026-03-29-Url-Based-Localization/images/blazor-webapp-tr.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

BIN
docs/en/Community-Articles/2026-03-29-Url-Based-Localization/images/module-identity-users.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
docs/en/Community-Articles/2026-03-29-Url-Based-Localization/images/mvc-home-en.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

BIN
docs/en/Community-Articles/2026-03-29-Url-Based-Localization/images/mvc-home-tr.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

1
docs/en/framework/fundamentals/index.md

@ -17,6 +17,7 @@ The following documents explains the fundamental building blocks to create ABP s
* [Dependency Injection](./dependency-injection.md)
* [Exception Handling](./exception-handling.md)
* [Localization](./localization.md)
* [URL-Based Localization](./url-based-localization.md)
* [Logging](./logging.md)
* [Object Extensions](./object-extensions.md)
* [Options](./options.md)

4
docs/en/framework/fundamentals/localization.md

@ -294,6 +294,10 @@ Configure<AbpLocalizationOptions>(options =>
});
```
## URL-Based Localization
ABP supports embedding the culture code directly in the URL path (e.g. `/en/products`, `/zh-Hans/about`), which is useful for SEO-friendly and shareable localized URLs. See the [URL-Based Localization](./url-based-localization.md) document for details.
## The Client Side
See the following documents to learn how to reuse the same localization texts in the JavaScript side;

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

@ -0,0 +1,173 @@
````json
//[doc-seo]
{
"Description": "Learn how to use ABP's URL-based localization to embed culture in the URL path, enabling SEO-friendly and shareable localized URLs."
}
````
# URL-Based Localization
ABP supports embedding the current culture directly in the URL path, for example `/tr/products` or `/en/about`. This approach is widely used by documentation sites, e-commerce platforms, and any site that needs SEO-friendly, shareable localized URLs.
By default, ABP detects language from QueryString (`?culture=tr`), Cookie, and `Accept-Language` header. URL path detection is **opt-in** and fully backward-compatible.
## Enabling URL-Based Localization
Configure the `AbpRequestLocalizationOptions` in your [module class](../architecture/modularity/basics.md):
````csharp
Configure<AbpRequestLocalizationOptions>(options =>
{
options.UseRouteBasedCulture = true;
});
````
That's all you need. The framework automatically handles the rest.
## What Happens Automatically
When you set `UseRouteBasedCulture` to `true`, ABP automatically registers the following:
* **`RouteDataRequestCultureProvider`** — A built-in ASP.NET Core provider that reads `{culture}` from route data. ABP inserts it after `QueryStringRequestCultureProvider` and before `CookieRequestCultureProvider`.
* **`{culture}/{controller}/{action}` route** — A conventional route for MVC controllers. The `{culture}` parameter uses a custom route constraint (`AbpCultureRouteConstraint`) that only matches culture values configured in `AbpLocalizationOptions.Languages`, so URLs like `/enterprise/products` are not mistaken for culture-prefixed routes.
* **`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 (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.
## URL Generation
When a request has a `{culture}` route value, all URL generation methods automatically include the culture prefix:
````csharp
// In a Razor Page — culture is auto-injected, no manual parameter needed
@Url.Page("/About") // Generates: /zh-Hans/About
@Url.Action("About", "Home") // Generates: /zh-Hans/Home/About
````
Menu items registered via `IMenuContributor` also automatically get the culture prefix. No changes are needed in your menu contributors or theme.
## Language Switching
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 |
|---|---|
| `/tr/products` | `/en/products` |
| `/tenant-a/zh-Hans/about` | `/tenant-a/en/about` |
| `/home?culture=tr&ui-culture=tr` | `/home?culture=en&ui-culture=en` |
| `/about` (no prefix) | `/about` (unchanged) |
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` — route registration, URL generation, menu links, and language switching. **No code changes are needed in your pages or controllers.**
## Blazor Server
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.
Culture detection, cookie persistence, menu URLs, and language switching all work automatically. No additional configuration is needed beyond the `UseRouteBasedCulture` option.
### 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, Settings, Account, etc.) already include `@page "/{culture}/..."` routes out of the box — you do not need to add them manually.
## 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.
Culture detection, cookie persistence, menu URLs, and language switching all work automatically. The WASM client reads the `UseRouteBasedCulture` flag from the server via `/api/abp/application-configuration`, so no client-side configuration is needed.
### What requires manual changes
Same as Blazor Server — you must manually add `@page "/{culture}/..."` routes to your Blazor pages.
## Angular
The [ABP Angular UI](../ui/angular/quick-start.md) runs in the browser. The server still applies `UseRouteBasedCulture`; the client reads **`localization.useRouteBasedCulture`** from `/api/abp/application-configuration` (same payload as other UI types). There is no separate Angular setting.
### Routing
Angular does not add a culture segment to your route config automatically. Use **`withOptionalRouteCulturePrefix`** from **`@abp/ng.core`** so one route tree matches both **`/identity/users`** and **`/en/identity/users`** (the first path segment is matched only when it looks like a culture code, e.g. `en`, `tr`, `zh-Hans`).
````typescript
import { Routes } from '@angular/router';
import { withOptionalRouteCulturePrefix } from '@abp/ng.core';
const appRoutesCore: Routes = [
// ... your routes (path: '', 'account', 'identity', lazy children, etc.)
];
export const appRoutes = withOptionalRouteCulturePrefix(appRoutesCore);
````
![Angular: routes wrapped with optional culture prefix](../../images/url-based-localization-angular-routes.png)
### URL → session language
When **`useRouteBasedCulture`** is **true**, **`RouteBasedCultureService`** (from `@abp/ng.core`) keeps the session language aligned with the first URL segment after navigation. This runs during application bootstrap and on each **`NavigationEnd`**.
### Menu links, breadcrumbs, and `routerLink`
Menu paths from **`RoutesService`** are usually **without** a culture prefix (`/identity/users`). Use the **`abpRouteCultureUrl`** pipe on **`routerLink`** (or **`RouteBasedCultureUrlService.prefixPathWithCulture`**) so links navigate to **`/en/identity/users`** when route-based culture is enabled. The **Basic** theme navigation and **Theme Shared** breadcrumb links follow this pattern.
![Angular: culture-prefixed menu or URL bar](../../images/url-based-localization-angular-menu-url.png)
### Language switcher (toolbar)
If the user selects a language in the UI, call **`RouteBasedCultureUrlService.applyLanguageSelection(cultureName)`** (or **`navigateToUrlWithCulture`**) instead of only updating the session language. That rewrites the current URL’s culture segment (or prepends it) so the address bar and session stay consistent; **`RouteBasedCultureService`** then picks up the culture from the URL after navigation.
### Active menu, breadcrumbs, and route matching
The browser URL may be **`/en/identity/users`** while menu items and **`RoutesService`** paths stay **`/identity/users`**. For comparisons (active state, **`findRoute`**, permission guard, dynamic layout), normalize the current URL with **`RouteBasedCultureUrlService.normalizeForMenuMatch`** (or **`stripCulturePrefixIfEnabled`**) or use **`getRoutePathForMatching`** where **`getRoutePath`** was used.
### Configuration refresh
**`RouteBasedCultureUrlService`** refreshes its cached **`useRouteBasedCulture`** and **languages** when application configuration is updated (for example after **`refreshAppState`**), so hot paths do not query configuration on every change detection cycle.
## Multi-Tenancy Compatibility
URL-based localization is fully compatible with [multi-tenancy URL routing](../architecture/multi-tenancy/index.md). The culture route is registered as a conventional route `{culture}/{controller}/{action}`. If your application uses tenant routing (e.g., `/{tenant}/...`), the tenant middleware strips the tenant segment before routing, and the culture segment is handled separately.
Language switching also supports tenant-prefixed URLs. For example, `/tenant-a/zh-Hans/About` correctly switches to `/tenant-a/en/About`.
## API Routes
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.
## Culture Detection Priority
ASP.NET Core has a built-in [`RouteDataRequestCultureProvider`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.localization.routing.routedatarequestcultureprovider) (in `Microsoft.AspNetCore.Localization.Routing`) that reads culture from route data, but it is not included in the default provider list. When `UseRouteBasedCulture` is enabled, ABP inserts it after `QueryStringRequestCultureProvider` and before `CookieRequestCultureProvider`. The resulting provider order is:
1. `QueryStringRequestCultureProvider` (ASP.NET Core default — useful for debugging and testing)
2. `RouteDataRequestCultureProvider` (URL path — inserted by ABP when enabled)
3. `CookieRequestCultureProvider` (ASP.NET Core default)
4. `AcceptLanguageHeaderRequestCultureProvider` (ASP.NET Core default)
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.

BIN
docs/en/images/url-based-localization-angular-menu-url.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
docs/en/images/url-based-localization-angular-routes.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

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

@ -0,0 +1,63 @@
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; }
protected IMenuItemCulturePrefixHelper MenuItemCulturePrefixHelper { get; }
public AbpWasmCultureMenuItemUrlProvider(
ICachedApplicationConfigurationClient configurationClient,
IMenuItemCulturePrefixHelper menuItemCulturePrefixHelper)
{
ConfigurationClient = configurationClient;
MenuItemCulturePrefixHelper = menuItemCulturePrefixHelper;
}
public virtual async Task HandleAsync(MenuItemUrlProviderContext context)
{
if (!OperatingSystem.IsBrowser())
{
return;
}
var config = await ConfigurationClient.GetAsync();
if (!config.Localization.UseRouteBasedCulture)
{
return;
}
var culture = GetCulture(config);
if (string.IsNullOrEmpty(culture))
{
return;
}
await MenuItemCulturePrefixHelper.PrependCulturePrefixAsync(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;
}
}

25
framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/CultureAwareAuthenticationBase.cs

@ -0,0 +1,25 @@
using Microsoft.AspNetCore.Components;
using Volo.Abp.AspNetCore.Components;
namespace Volo.Abp.AspNetCore.Components.WebAssembly.Theming;
/// <summary>
/// Shared base for WASM theme <c>Authentication</c> pages.
/// Provides a <see cref="GetCultureAwareHomeUrl"/> helper so the culture-aware
/// home URL construction is not duplicated across theme packages.
/// </summary>
public abstract class CultureAwareAuthenticationBase : AbpComponentBase
{
[Inject]
protected NavigationManager Navigation { get; set; } = default!;
[Parameter]
public string? Culture { get; set; }
protected virtual string GetCultureAwareHomeUrl()
{
return string.IsNullOrEmpty(Culture)
? Navigation.BaseUri
: Navigation.BaseUri + Culture + "/";
}
}

35
framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/CultureAwareRedirectToLoginHelper.cs

@ -0,0 +1,35 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.WebAssembly.Authentication;
using Microsoft.Extensions.Options;
using Volo.Abp.AspNetCore.Components.Web;
using Volo.Abp.AspNetCore.Components.WebAssembly;
namespace Volo.Abp.AspNetCore.Components.WebAssembly.Theming;
/// <summary>
/// Provides the shared culture-aware login redirect logic for all WASM theme
/// <c>WebAssemblyRedirectToLogin</c> components. Each theme must keep
/// <c>@inherits RedirectToLogin</c> for ABP service-replacement assignability,
/// so a common component base class is not feasible; this static helper
/// centralises the logic instead.
/// </summary>
public static class CultureAwareRedirectToLoginHelper
{
public static async Task RedirectAsync(
NavigationManager navigation,
string loginUrl,
IRouteBasedCultureUrlHelper cultureUrlHelper,
IOptions<AbpAspNetCoreComponentsWebOptions> webOptions)
{
var cultureLoginUrl = await cultureUrlHelper.PrependCulturePrefixAsync(loginUrl);
if (webOptions.Value.IsBlazorWebApp)
{
navigation.NavigateTo(cultureLoginUrl, forceLoad: true);
}
else
{
navigation.NavigateToLogin(cultureLoginUrl);
}
}
}

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

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

14
framework/src/Volo.Abp.AspNetCore.Components.WebAssembly/Volo/Abp/AspNetCore/Components/WebAssembly/IRouteBasedCultureNavigationHelper.cs

@ -0,0 +1,14 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
using Volo.Abp.Localization;
namespace Volo.Abp.AspNetCore.Components.WebAssembly;
public interface IRouteBasedCultureNavigationHelper
{
Task NavigateToNewCultureAsync(
NavigationManager navigationManager,
LanguageInfo newLanguage,
IEnumerable<LanguageInfo> allLanguages);
}

14
framework/src/Volo.Abp.AspNetCore.Components.WebAssembly/Volo/Abp/AspNetCore/Components/WebAssembly/IRouteBasedCultureUrlHelper.cs

@ -0,0 +1,14 @@
using System.Threading.Tasks;
namespace Volo.Abp.AspNetCore.Components.WebAssembly;
public interface IRouteBasedCultureUrlHelper
{
/// <summary>
/// Prepends the current culture to <paramref name="url"/> when route-based culture is enabled
/// and the current culture is a known application language.
/// Returns the original <paramref name="url"/> unchanged when the feature is disabled or the
/// culture is not recognised.
/// </summary>
Task<string> PrependCulturePrefixAsync(string url);
}

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

@ -0,0 +1,56 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
using Volo.Abp.DependencyInjection;
using Volo.Abp.Localization;
namespace Volo.Abp.AspNetCore.Components.WebAssembly;
public class RouteBasedCultureNavigationHelper : IRouteBasedCultureNavigationHelper, ITransientDependency
{
public virtual Task NavigateToNewCultureAsync(
NavigationManager navigationManager,
LanguageInfo newLanguage,
IEnumerable<LanguageInfo> allLanguages)
{
var relativePath = navigationManager.ToBaseRelativePath(navigationManager.Uri);
// Separate the path from any query string or fragment so the culture segment
// is correctly identified even for URLs like "tr?x=1" (no slash after culture).
var suffixIndex = relativePath.IndexOfAny(['?', '#']);
var pathPart = suffixIndex >= 0 ? relativePath.Substring(0, suffixIndex) : relativePath;
var suffix = suffixIndex >= 0 ? relativePath.Substring(suffixIndex) : string.Empty;
var slashIndex = pathPart.IndexOf('/');
var firstSegment = GetFirstPathSegment(relativePath);
var pathRemainder = slashIndex >= 0 ? pathPart.Substring(slashIndex) : string.Empty;
// No-op: the current URL already shows the target culture — no navigation needed.
if (string.Equals(firstSegment, newLanguage.CultureName, StringComparison.OrdinalIgnoreCase))
{
return Task.CompletedTask;
}
var newRelativePath = allLanguages.Any(l => string.Equals(l.CultureName, firstSegment, StringComparison.OrdinalIgnoreCase))
? newLanguage.CultureName + pathRemainder + suffix
: newLanguage.CultureName + "/" + pathPart + suffix;
navigationManager.NavigateTo(navigationManager.ToAbsoluteUri(newRelativePath).ToString(), forceLoad: true);
return Task.CompletedTask;
}
/// <summary>
/// Returns the first path segment of <paramref name="baseRelativePath"/>,
/// stripping any query string or fragment before splitting on '/'.
/// For example: "zh-Hans/account?x=1" → "zh-Hans", "tr/home#top" → "tr".
/// </summary>
protected virtual string GetFirstPathSegment(string baseRelativePath)
{
var suffixIndex = baseRelativePath.IndexOfAny(['?', '#']);
var pathPart = suffixIndex >= 0 ? baseRelativePath.Substring(0, suffixIndex) : baseRelativePath;
var slashIndex = pathPart.IndexOf('/');
return slashIndex >= 0 ? pathPart.Substring(0, slashIndex) : pathPart;
}
}

90
framework/src/Volo.Abp.AspNetCore.Components.WebAssembly/Volo/Abp/AspNetCore/Components/WebAssembly/RouteBasedCultureUrlHelper.cs

@ -0,0 +1,90 @@
using System;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using Volo.Abp.AspNetCore.Mvc.Client;
using Volo.Abp.DependencyInjection;
namespace Volo.Abp.AspNetCore.Components.WebAssembly;
public class RouteBasedCultureUrlHelper : IRouteBasedCultureUrlHelper, ITransientDependency
{
private readonly ICachedApplicationConfigurationClient _configurationClient;
public RouteBasedCultureUrlHelper(ICachedApplicationConfigurationClient configurationClient)
{
_configurationClient = configurationClient;
}
public virtual async Task<string> PrependCulturePrefixAsync(string url)
{
if (string.IsNullOrEmpty(url))
{
return url;
}
// Skip absolute URLs with a web scheme and protocol-relative URLs.
// Intentionally avoids Uri.TryCreate here: on Unix, root-relative paths such as
// "/account/login" are parsed as absolute file:// URIs, which would incorrectly
// skip them before the culture prefix could be applied.
if (url.StartsWith("//", StringComparison.Ordinal) ||
url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) ||
url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
return url;
}
var config = await _configurationClient.GetAsync();
if (config?.Localization.UseRouteBasedCulture != true)
{
return url;
}
var currentCulture = CultureInfo.CurrentCulture.Name;
var isKnownCulture = config.Localization.Languages
.Any(l => string.Equals(l.CultureName, currentCulture, StringComparison.OrdinalIgnoreCase));
if (!isKnownCulture)
{
return url;
}
// Idempotency guard: if the URL already carries the culture prefix, return it unchanged.
// Strip the leading scheme prefix (~/ or /) before checking the first path segment.
var pathForSegmentCheck = url.StartsWith("~/", StringComparison.Ordinal) ? url.Substring(2)
: url.StartsWith("/", StringComparison.Ordinal) ? url.Substring(1)
: url;
if (string.Equals(GetFirstPathSegment(pathForSegmentCheck),
currentCulture, StringComparison.OrdinalIgnoreCase))
{
return url;
}
if (url.StartsWith("~/", StringComparison.Ordinal))
{
return "~/" + currentCulture + "/" + url.Substring(2);
}
if (url.StartsWith("/", StringComparison.Ordinal))
{
return "/" + currentCulture + url;
}
// Bare relative path (e.g. "authentication/login")
return currentCulture + "/" + url;
}
/// <summary>
/// Returns the first path segment of <paramref name="baseRelativePath"/>,
/// stripping any query string or fragment before splitting on '/'.
/// For example: "zh-Hans/account?x=1" → "zh-Hans", "tr/home#top" → "tr".
/// </summary>
protected virtual string GetFirstPathSegment(string baseRelativePath)
{
var suffixIndex = baseRelativePath.IndexOfAny(['?', '#']);
var pathPart = suffixIndex >= 0 ? baseRelativePath.Substring(0, suffixIndex) : baseRelativePath;
var slashIndex = pathPart.IndexOf('/');
return slashIndex >= 0 ? pathPart.Substring(0, slashIndex) : pathPart;
}
}

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

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

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

@ -16,6 +16,8 @@ using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.AspNetCore.Mvc.DataAnnotations;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.RequestLocalization;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Localization;
@ -212,6 +214,45 @@ public class AbpAspNetCoreMvcModule : AbpModule
{
preConfigureActions.Configure(options);
});
ConfigureRouteBasedCulture(context);
}
protected virtual void ConfigureRouteBasedCulture(ServiceConfigurationContext context)
{
context.Services.Configure<RouteOptions>(options =>
{
options.ConstraintMap["culture"] = typeof(AbpCultureRouteConstraint);
});
context.Services
.AddOptions<AbpEndpointRouterOptions>()
.PostConfigure<IOptions<AbpRequestLocalizationOptions>>((routerOptions, abpLocOptions) =>
{
if (abpLocOptions.Value.UseRouteBasedCulture)
{
routerOptions.EndpointConfigureActions.Insert(0, endpointContext =>
{
endpointContext.Endpoints.MapControllerRoute(
"AbpCultureRoute",
AbpCultureRoutePagesConvention.CultureRouteTemplate + "/{controller=Home}/{action=Index}/{id?}");
});
}
});
context.Services
.AddOptions<RazorPagesOptions>()
.PostConfigure<IOptions<AbpRequestLocalizationOptions>>((pagesOptions, abpLocOptions) =>
{
if (abpLocOptions.Value.UseRouteBasedCulture &&
!pagesOptions.Conventions.OfType<AbpCultureRoutePagesConvention>().Any())
{
pagesOptions.Conventions.Add(new AbpCultureRoutePagesConvention());
}
});
context.Services.TryAddSingleton<UrlHelperFactory>();
context.Services.Replace(ServiceDescriptor.Singleton<IUrlHelperFactory, AbpCultureRouteUrlHelperFactory>());
}
public override void OnApplicationInitialization(ApplicationInitializationContext context)

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

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

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

@ -1,6 +1,10 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.WebUtilities;
using Volo.Abp.DependencyInjection;
namespace Volo.Abp.AspNetCore.Mvc.Localization;
@ -9,25 +13,71 @@ public class AbpAspNetCoreMvcQueryStringCultureReplacement : IQueryStringCulture
{
public virtual Task ReplaceAsync(QueryStringCultureReplacementContext context)
{
if (!string.IsNullOrWhiteSpace(context.ReturnUrl))
if (string.IsNullOrWhiteSpace(context.ReturnUrl))
{
if (context.ReturnUrl.Contains("culture=", StringComparison.OrdinalIgnoreCase) &&
context.ReturnUrl.Contains("ui-Culture=", StringComparison.OrdinalIgnoreCase))
{
context.ReturnUrl = Regex.Replace(
context.ReturnUrl,
"culture=[A-Za-z-]+",
$"culture={context.RequestCulture.Culture}",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
context.ReturnUrl = Regex.Replace(
context.ReturnUrl,
"ui-culture=[A-Za-z-]+",
$"ui-culture={context.RequestCulture.UICulture}",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
}
return Task.CompletedTask;
}
var currentCulture = context.CurrentCulture
?? context.HttpContext.GetRouteValue("culture")?.ToString();
if (!string.IsNullOrEmpty(currentCulture))
{
var escapedCulture = Regex.Escape(currentCulture);
// Replace only the first occurrence so that paths like /en/products/en/details
// only have the leading culture segment replaced, while tenant-prefixed paths
// like /tenant-a/en/... are also handled correctly.
var pattern = $"/{escapedCulture}(?=/|$|\\?|#)";
context.ReturnUrl = new Regex(pattern, RegexOptions.IgnoreCase)
.Replace(context.ReturnUrl, "/" + context.RequestCulture.Culture.Name, 1);
}
context.ReturnUrl = ReplaceQueryStringCulture(context.ReturnUrl, context);
return Task.CompletedTask;
}
/// <summary>
/// Replaces <c>culture</c> and <c>ui-culture</c> query parameters in <paramref name="url"/>
/// with the values from <paramref name="context"/>. Each parameter is handled independently —
/// the presence of one does not require the other. Uses a proper query parser instead of
/// regex to avoid false-positive matches inside other parameter values.
/// </summary>
protected virtual string ReplaceQueryStringCulture(string url, QueryStringCultureReplacementContext context)
{
var fragmentIndex = url.IndexOf('#');
var fragment = fragmentIndex >= 0 ? url.Substring(fragmentIndex) : string.Empty;
var urlWithoutFragment = fragmentIndex >= 0 ? url.Substring(0, fragmentIndex) : url;
var queryIndex = urlWithoutFragment.IndexOf('?');
if (queryIndex < 0)
{
return url;
}
var path = urlWithoutFragment.Substring(0, queryIndex);
var queryString = urlWithoutFragment.Substring(queryIndex);
var query = QueryHelpers.ParseQuery(queryString);
if (!query.ContainsKey("culture") && !query.ContainsKey("ui-culture"))
{
return url;
}
if (query.ContainsKey("culture"))
{
query["culture"] = context.RequestCulture.Culture.Name;
}
if (query.ContainsKey("ui-culture"))
{
query["ui-culture"] = context.RequestCulture.UICulture.Name;
}
var rebuiltUrl = QueryHelpers.AddQueryString(
path,
query.SelectMany(kvp => kvp.Value.Select(v => KeyValuePair.Create<string, string?>(kvp.Key, v))));
return rebuiltUrl + fragment;
}
}

71
framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureAwareUrlHelper.cs

@ -0,0 +1,71 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.Routing;
namespace Volo.Abp.AspNetCore.Mvc.Localization;
/// <summary>
/// Wraps an <see cref="IUrlHelper"/> to automatically inject the culture route value
/// into all URL generation calls.
/// </summary>
public class AbpCultureAwareUrlHelper : IUrlHelper
{
protected IUrlHelper Inner { get; }
protected string Culture { get; }
public AbpCultureAwareUrlHelper(IUrlHelper inner, string culture)
{
Inner = inner;
Culture = culture;
}
public ActionContext ActionContext => Inner.ActionContext;
public virtual string? Action(UrlActionContext actionContext)
{
var values = new RouteValueDictionary(actionContext.Values);
values.TryAdd("culture", Culture);
return Inner.Action(new UrlActionContext
{
Action = actionContext.Action,
Controller = actionContext.Controller,
Values = values,
Protocol = actionContext.Protocol,
Host = actionContext.Host,
Fragment = actionContext.Fragment,
});
}
public virtual string? Content(string? contentPath)
{
return Inner.Content(contentPath);
}
public virtual bool IsLocalUrl(string? url)
{
return Inner.IsLocalUrl(url);
}
public virtual string? Link(string? routeName, object? values)
{
var rvd = new RouteValueDictionary(values);
rvd.TryAdd("culture", Culture);
return Inner.Link(routeName, rvd);
}
public virtual string? RouteUrl(UrlRouteContext routeContext)
{
var values = new RouteValueDictionary(routeContext.Values);
values.TryAdd("culture", Culture);
return Inner.RouteUrl(new UrlRouteContext
{
RouteName = routeContext.RouteName,
Values = values,
Protocol = routeContext.Protocol,
Host = routeContext.Host,
Fragment = routeContext.Fragment,
});
}
}

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

@ -0,0 +1,69 @@
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.DependencyInjection;
using Volo.Abp.Localization;
using Volo.Abp.UI.Navigation;
namespace Volo.Abp.AspNetCore.Mvc.Localization;
/// <summary>
/// Prepends the culture route prefix to menu item URLs when route-based culture is enabled.
/// </summary>
public class AbpCultureMenuItemUrlProvider : IMenuItemUrlProvider, ITransientDependency
{
protected IHttpContextAccessor HttpContextAccessor { get; }
protected IOptions<AbpRequestLocalizationOptions> LocalizationOptions { get; }
protected IOptions<AbpLocalizationOptions> AbpLocalizationOptions { get; }
protected IMenuItemCulturePrefixHelper MenuItemCulturePrefixHelper { get; }
public AbpCultureMenuItemUrlProvider(
IHttpContextAccessor httpContextAccessor,
IOptions<AbpRequestLocalizationOptions> localizationOptions,
IOptions<AbpLocalizationOptions> abpLocalizationOptions,
IMenuItemCulturePrefixHelper menuItemCulturePrefixHelper)
{
HttpContextAccessor = httpContextAccessor;
LocalizationOptions = localizationOptions;
AbpLocalizationOptions = abpLocalizationOptions;
MenuItemCulturePrefixHelper = menuItemCulturePrefixHelper;
}
public virtual async Task HandleAsync(MenuItemUrlProviderContext context)
{
if (!LocalizationOptions.Value.UseRouteBasedCulture)
{
return;
}
var culture = GetCulture();
if (string.IsNullOrEmpty(culture))
{
return;
}
await MenuItemCulturePrefixHelper.PrependCulturePrefixAsync(context.Menu, "/" + culture);
}
protected virtual string? GetCulture()
{
var httpContext = HttpContextAccessor.HttpContext;
if (httpContext != null)
{
return AbpRequestCultureCookieHelper.GetRouteCulture(httpContext);
}
// No HttpContext: fallback to CurrentCulture.
var currentCulture = CultureInfo.CurrentCulture.Name;
var isKnownCulture = AbpLocalizationOptions.Value.Languages
.Any(l => string.Equals(l.CultureName, currentCulture, StringComparison.OrdinalIgnoreCase));
return isKnownCulture ? currentCulture : null;
}
}

37
framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureRouteConstraint.cs

@ -0,0 +1,37 @@
using System;
using System.Linq;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Volo.Abp.Localization;
namespace Volo.Abp.AspNetCore.Mvc.Localization;
/// <summary>
/// A route constraint that only matches culture values configured in
/// <see cref="AbpLocalizationOptions.Languages"/>.
/// </summary>
public class AbpCultureRouteConstraint : IRouteConstraint
{
public virtual bool Match(HttpContext? httpContext, IRouter? route, string routeKey,
RouteValueDictionary values, RouteDirection routeDirection)
{
if (!values.TryGetValue(routeKey, out var value) || value is not string cultureValue)
{
return false;
}
var languages = httpContext?.RequestServices
.GetService<IOptions<AbpLocalizationOptions>>()?.Value.Languages;
if (languages == null || languages.Count == 0)
{
// During URL generation, HttpContext or services may not be available.
return routeDirection == RouteDirection.UrlGeneration;
}
return languages.Any(l =>
string.Equals(l.CultureName, cultureValue, StringComparison.OrdinalIgnoreCase));
}
}

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

@ -0,0 +1,47 @@
using System.Collections.Generic;
using System.Linq;
using Microsoft.AspNetCore.Mvc.ApplicationModels;
namespace Volo.Abp.AspNetCore.Mvc.Localization;
/// <summary>
/// Adds a {culture}-prefixed route selector to every Razor Page.
/// Automatically registered when <c>UseRouteBasedCulture</c> is <c>true</c>.
/// </summary>
public class AbpCultureRoutePagesConvention : IPageRouteModelConvention
{
/// <summary>
/// Route parameter template using the custom "culture" route constraint registered in
/// <see cref="AbpCultureRouteConstraint"/>. The constraint only matches culture values
/// configured in <see cref="Volo.Abp.Localization.AbpLocalizationOptions.Languages"/>.
/// </summary>
internal const string CultureRouteTemplate = "{culture:culture}";
public virtual void Apply(PageRouteModel model)
{
var selectorsToAdd = new List<SelectorModel>();
foreach (var selector in model.Selectors.ToList())
{
var originalTemplate = selector.AttributeRouteModel?.Template?.TrimStart('/');
if (originalTemplate == null)
{
continue;
}
selectorsToAdd.Add(new SelectorModel
{
AttributeRouteModel = new AttributeRouteModel
{
Template = AttributeRouteModel.CombineTemplates(CultureRouteTemplate, originalTemplate),
Order = -1
}
});
}
foreach (var selector in selectorsToAdd)
{
model.Selectors.Add(selector);
}
}
}

49
framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureRouteUrlHelperFactory.cs

@ -0,0 +1,49 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.RequestLocalization;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Options;
namespace Volo.Abp.AspNetCore.Mvc.Localization;
/// <summary>
/// Wraps the default <see cref="UrlHelperFactory"/> to automatically inject the culture
/// route value into all URL generation calls when the current request has a {culture} route value.
/// Only activates when <see cref="AbpRequestLocalizationOptions.UseRouteBasedCulture"/> is <c>true</c>.
/// </summary>
public class AbpCultureRouteUrlHelperFactory : IUrlHelperFactory
{
protected UrlHelperFactory Inner { get; }
protected IOptions<AbpRequestLocalizationOptions> LocalizationOptions { get; }
public AbpCultureRouteUrlHelperFactory(
UrlHelperFactory inner,
IOptions<AbpRequestLocalizationOptions> localizationOptions)
{
Inner = inner;
LocalizationOptions = localizationOptions;
}
public virtual IUrlHelper GetUrlHelper(ActionContext context)
{
var urlHelper = Inner.GetUrlHelper(context);
if (!LocalizationOptions.Value.UseRouteBasedCulture)
{
return urlHelper;
}
if (context.RouteData.Values.TryGetValue("culture", out var culture) &&
culture != null)
{
return CreateCultureAwareUrlHelper(urlHelper, culture.ToString()!);
}
return urlHelper;
}
protected virtual AbpCultureAwareUrlHelper CreateCultureAwareUrlHelper(IUrlHelper urlHelper, string culture)
{
return new AbpCultureAwareUrlHelper(urlHelper, culture);
}
}

24
framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpLanguagesController.cs

@ -1,7 +1,8 @@
using Microsoft.AspNetCore.Localization;
using Microsoft.AspNetCore.Mvc;
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Localization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RequestLocalization;
using Volo.Abp.Auditing;
using Volo.Abp.Localization;
@ -42,7 +43,12 @@ public class AbpLanguagesController : AbpController
HttpContext.Items[AbpRequestLocalizationMiddleware.HttpContextItemName] = true;
var context = new QueryStringCultureReplacementContext(HttpContext, new RequestCulture(culture, uiCulture), returnUrl);
var context = new QueryStringCultureReplacementContext(
HttpContext,
new RequestCulture(culture, uiCulture),
returnUrl,
GetCurrentCultureFromRequestCookie());
await QueryStringCultureReplacement.ReplaceAsync(context);
if (!string.IsNullOrWhiteSpace(context.ReturnUrl))
@ -53,6 +59,18 @@ public class AbpLanguagesController : AbpController
return Redirect("~/");
}
protected virtual string? GetCurrentCultureFromRequestCookie()
{
var cookieValue = HttpContext.Request.Cookies[CookieRequestCultureProvider.DefaultCookieName];
if (cookieValue == null)
{
return null;
}
var result = CookieRequestCultureProvider.ParseCookieValue(cookieValue);
return result?.Cultures.FirstOrDefault().Value;
}
protected virtual string GetRedirectUrl(string returnUrl)
{
if (returnUrl.IsNullOrEmpty())

11
framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/QueryStringCultureReplacementContext.cs

@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Localization;
namespace Volo.Abp.AspNetCore.Mvc.Localization;
@ -11,10 +11,17 @@ public class QueryStringCultureReplacementContext
public string ReturnUrl { get; set; }
public QueryStringCultureReplacementContext(HttpContext httpContext, RequestCulture requestCulture, string returnUrl)
public string? CurrentCulture { get; }
public QueryStringCultureReplacementContext(
HttpContext httpContext,
RequestCulture requestCulture,
string returnUrl,
string? currentCulture = null)
{
HttpContext = httpContext;
RequestCulture = requestCulture;
ReturnUrl = returnUrl;
CurrentCulture = currentCulture;
}
}

47
framework/src/Volo.Abp.AspNetCore/Microsoft/AspNetCore/RequestLocalization/AbpRequestCultureCookieHelper.cs

@ -1,11 +1,41 @@
using System;
using System.Globalization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Localization;
using Microsoft.AspNetCore.Routing;
namespace Microsoft.AspNetCore.RequestLocalization;
public static class AbpRequestCultureCookieHelper
{
public const string HasRouteCultureCookieName = "Abp.HasRouteCulture";
/// <summary>
/// Gets the current route culture from the request. First checks route values,
/// then falls back to the HasRouteCulture cookie (set during Blazor SSR) with CurrentCulture.
/// Returns null if the request has no route-based culture.
/// </summary>
public static string? GetRouteCulture(HttpContext? httpContext)
{
if (httpContext == null)
{
return null;
}
var routeCulture = httpContext.GetRouteValue("culture")?.ToString();
if (!string.IsNullOrEmpty(routeCulture))
{
return routeCulture;
}
if (httpContext.Request.Cookies.ContainsKey(HasRouteCultureCookieName))
{
return CultureInfo.CurrentCulture.Name;
}
return null;
}
public static void SetCultureCookie(
HttpContext httpContext,
RequestCulture requestCulture)
@ -20,4 +50,21 @@ public static class AbpRequestCultureCookieHelper
}
);
}
public static void SetHasRouteCultureCookie(HttpContext httpContext, bool hasRouteCulture)
{
if (hasRouteCulture)
{
httpContext.Response.Cookies.Append(
HasRouteCultureCookieName, "1",
new CookieOptions
{
IsEssential = true
});
}
else
{
httpContext.Response.Cookies.Delete(HasRouteCultureCookieName);
}
}
}

21
framework/src/Volo.Abp.AspNetCore/Microsoft/AspNetCore/RequestLocalization/AbpRequestLocalizationMiddleware.cs

@ -1,7 +1,9 @@
using System.Threading.Tasks;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Localization;
using Microsoft.AspNetCore.Localization.Routing;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Volo.Abp.AspNetCore.Middleware;
@ -39,13 +41,28 @@ public class AbpRequestLocalizationMiddleware : AbpMiddlewareBase, ITransientDep
if (context.Items[HttpContextItemName] == null)
{
var requestCultureFeature = context.Features.Get<IRequestCultureFeature>();
if (requestCultureFeature?.Provider is QueryStringRequestCultureProvider)
if (requestCultureFeature?.Provider is QueryStringRequestCultureProvider
or RouteDataRequestCultureProvider)
{
AbpRequestCultureCookieHelper.SetCultureCookie(
context,
requestCultureFeature.RequestCulture
);
}
// Only manage HasRouteCulture cookie for Blazor component page requests.
// This cookie is used by AbpCultureMenuItemUrlProvider to determine if the
// initial SSR page had a culture prefix, since the Blazor interactive circuit
// (/_blazor) does not carry the original route values.
// Note: ComponentTypeMetadata is an internal ASP.NET Core type
// (Microsoft.AspNetCore.Components.Endpoints.ComponentTypeMetadata).
// We match by full type name to avoid false positives from other assemblies.
var endpoint = context.GetEndpoint();
if (endpoint?.Metadata.Any(m => m.GetType().FullName == "Microsoft.AspNetCore.Components.Endpoints.ComponentTypeMetadata") == true)
{
AbpRequestCultureCookieHelper.SetHasRouteCultureCookie(
context, requestCultureFeature?.Provider is RouteDataRequestCultureProvider);
}
}
return Task.CompletedTask;

6
framework/src/Volo.Abp.AspNetCore/Microsoft/AspNetCore/RequestLocalization/AbpRequestLocalizationOptions.cs

@ -9,6 +9,12 @@ public class AbpRequestLocalizationOptions
{
public List<Func<IServiceProvider, RequestLocalizationOptions, Task>> RequestLocalizationOptionConfigurators { get; }
/// <summary>
/// Enables culture detection from route data (e.g. /{culture}/page).
/// Default value: false.
/// </summary>
public bool UseRouteBasedCulture { get; set; }
public AbpRequestLocalizationOptions()
{
RequestLocalizationOptionConfigurators = new List<Func<IServiceProvider, RequestLocalizationOptions, Task>>();

16
framework/src/Volo.Abp.AspNetCore/Microsoft/AspNetCore/RequestLocalization/DefaultAbpRequestLocalizationOptionsProvider.cs

@ -6,6 +6,7 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Localization;
using Microsoft.AspNetCore.Localization.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Volo.Abp.DependencyInjection;
@ -70,9 +71,18 @@ public class DefaultAbpRequestLocalizationOptionsProvider :
.ToArray()
};
foreach (var configurator in serviceScope.ServiceProvider
.GetRequiredService<IOptions<AbpRequestLocalizationOptions>>()
.Value.RequestLocalizationOptionConfigurators)
var abpRequestLocalizationOptions = serviceScope.ServiceProvider
.GetRequiredService<IOptions<AbpRequestLocalizationOptions>>()
.Value;
if (abpRequestLocalizationOptions.UseRouteBasedCulture)
{
options.RequestCultureProviders.InsertAfter(
p => p is QueryStringRequestCultureProvider,
new RouteDataRequestCultureProvider());
}
foreach (var configurator in abpRequestLocalizationOptions.RequestLocalizationOptionConfigurators)
{
await configurator(serviceScope.ServiceProvider, options);
}

8
framework/src/Volo.Abp.UI.Navigation/Volo/Abp/Ui/Navigation/IMenuItemCulturePrefixHelper.cs

@ -0,0 +1,8 @@
using System.Threading.Tasks;
namespace Volo.Abp.UI.Navigation;
public interface IMenuItemCulturePrefixHelper
{
Task PrependCulturePrefixAsync(IHasMenuItems menuWithItems, string prefix);
}

13
framework/src/Volo.Abp.UI.Navigation/Volo/Abp/Ui/Navigation/IMenuItemUrlProvider.cs

@ -0,0 +1,13 @@
using System.Threading.Tasks;
namespace Volo.Abp.UI.Navigation;
/// <summary>
/// Provides a way to modify menu item URLs after the menu is fully configured.
/// Implementations can transform URLs based on the current request context
/// (e.g. adding a culture prefix for URL-based localization).
/// </summary>
public interface IMenuItemUrlProvider
{
Task HandleAsync(MenuItemUrlProviderContext context);
}

33
framework/src/Volo.Abp.UI.Navigation/Volo/Abp/Ui/Navigation/MenuItemCulturePrefixHelper.cs

@ -0,0 +1,33 @@
using System.Threading.Tasks;
using Volo.Abp.DependencyInjection;
namespace Volo.Abp.UI.Navigation;
public class MenuItemCulturePrefixHelper : IMenuItemCulturePrefixHelper, ITransientDependency
{
public virtual Task PrependCulturePrefixAsync(IHasMenuItems menuWithItems, string prefix)
{
PrependCulturePrefix(menuWithItems, prefix);
return Task.CompletedTask;
}
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.Substring(1);
}
else if (item.Url.StartsWith("/"))
{
item.Url = prefix + item.Url;
}
}
PrependCulturePrefix(item, prefix);
}
}
}

11
framework/src/Volo.Abp.UI.Navigation/Volo/Abp/Ui/Navigation/MenuItemUrlProviderContext.cs

@ -0,0 +1,11 @@
namespace Volo.Abp.UI.Navigation;
public class MenuItemUrlProviderContext
{
public ApplicationMenu Menu { get; }
public MenuItemUrlProviderContext(ApplicationMenu menu)
{
Menu = menu;
}
}

6
framework/src/Volo.Abp.UI.Navigation/Volo/Abp/Ui/Navigation/MenuManager.cs

@ -92,6 +92,12 @@ public class MenuManager : IMenuManager, ITransientDependency
await CheckPermissionsAsync(scope.ServiceProvider, menu);
}
var urlProviderContext = new MenuItemUrlProviderContext(menu);
foreach (var urlProvider in scope.ServiceProvider.GetServices<IMenuItemUrlProvider>())
{
await urlProvider.HandleAsync(urlProviderContext);
}
}
NormalizeMenu(menu);

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

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

6
framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/AbpAspNetCoreMvcTestModule.cs

@ -11,6 +11,7 @@ using Volo.Abp.AspNetCore.Mvc.GlobalFeatures;
using Volo.Abp.AspNetCore.Mvc.Libs;
using Volo.Abp.AspNetCore.Mvc.Localization;
using Volo.Abp.AspNetCore.Mvc.Localization.Resource;
using Microsoft.AspNetCore.RequestLocalization;
using Volo.Abp.AspNetCore.Security.Claims;
using Volo.Abp.AspNetCore.TestBase;
using Volo.Abp.Authorization;
@ -121,6 +122,11 @@ public class AbpAspNetCoreMvcTestModule : AbpModule
options.Languages.Add(new LanguageInfo("el", "el", "Ελληνικά"));
});
Configure<AbpRequestLocalizationOptions>(options =>
{
options.UseRouteBasedCulture = true;
});
Configure<RazorPagesOptions>(options =>
{
options.RootDirectory = "/Volo/Abp/AspNetCore/Mvc";

137
framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureAwareUrlHelper_Tests.cs

@ -0,0 +1,137 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.RequestLocalization;
using Microsoft.AspNetCore.Routing;
using NSubstitute;
using Shouldly;
using Volo.Abp.AspNetCore.Mvc.Localization;
using Xunit;
namespace Volo.Abp.AspNetCore.Mvc.Localization;
public class AbpCultureAwareUrlHelper_Tests
{
[Fact]
public void Action_Should_Inject_Culture()
{
var inner = Substitute.For<IUrlHelper>();
inner.Action(Arg.Any<UrlActionContext>()).Returns(callInfo =>
{
var ctx = callInfo.Arg<UrlActionContext>();
var values = new RouteValueDictionary(ctx.Values);
return values.ContainsKey("culture") ? $"/{values["culture"]}/{ctx.Controller}/{ctx.Action}" : $"/{ctx.Controller}/{ctx.Action}";
});
var helper = new AbpCultureAwareUrlHelper(inner, "zh-Hans");
var result = helper.Action(new UrlActionContext { Controller = "Home", Action = "Index" });
result.ShouldContain("zh-Hans");
}
[Fact]
public void Action_Should_Not_Override_Explicit_Culture()
{
var inner = Substitute.For<IUrlHelper>();
inner.Action(Arg.Any<UrlActionContext>()).Returns(callInfo =>
{
var ctx = callInfo.Arg<UrlActionContext>();
var values = new RouteValueDictionary(ctx.Values);
return $"/{values["culture"]}/Home/Index";
});
var helper = new AbpCultureAwareUrlHelper(inner, "zh-Hans");
var result = helper.Action(new UrlActionContext
{
Controller = "Home",
Action = "Index",
Values = new { culture = "en" }
});
// Explicit "en" should not be overridden by "zh-Hans"
result.ShouldBe("/en/Home/Index");
}
[Fact]
public void RouteUrl_Should_Inject_Culture()
{
var inner = Substitute.For<IUrlHelper>();
inner.RouteUrl(Arg.Any<UrlRouteContext>()).Returns(callInfo =>
{
var ctx = callInfo.Arg<UrlRouteContext>();
var values = new RouteValueDictionary(ctx.Values);
return values.ContainsKey("culture") ? $"/{values["culture"]}/page" : "/page";
});
var helper = new AbpCultureAwareUrlHelper(inner, "tr");
var result = helper.RouteUrl(new UrlRouteContext());
result.ShouldBe("/tr/page");
}
[Fact]
public void Content_Should_Pass_Through()
{
var inner = Substitute.For<IUrlHelper>();
inner.Content("~/test").Returns("/test");
var helper = new AbpCultureAwareUrlHelper(inner, "en");
helper.Content("~/test").ShouldBe("/test");
}
[Fact]
public void IsLocalUrl_Should_Pass_Through()
{
var inner = Substitute.For<IUrlHelper>();
inner.IsLocalUrl("/test").Returns(true);
var helper = new AbpCultureAwareUrlHelper(inner, "en");
helper.IsLocalUrl("/test").ShouldBeTrue();
}
[Fact]
public void Factory_Should_Return_CultureAwareHelper_When_Culture_In_Route()
{
var factory = CreateFactory(useRouteBasedCulture: true);
var httpContext = new DefaultHttpContext();
httpContext.Request.RouteValues["culture"] = "tr";
var actionContext = new ActionContext(httpContext, new RouteData(httpContext.Request.RouteValues), new Microsoft.AspNetCore.Mvc.Abstractions.ActionDescriptor());
var urlHelper = factory.GetUrlHelper(actionContext);
urlHelper.ShouldBeOfType<AbpCultureAwareUrlHelper>();
}
[Fact]
public void Factory_Should_Return_Default_Helper_When_No_Culture()
{
var factory = CreateFactory(useRouteBasedCulture: true);
var httpContext = new DefaultHttpContext();
var actionContext = new ActionContext(httpContext, new RouteData(), new Microsoft.AspNetCore.Mvc.Abstractions.ActionDescriptor());
var urlHelper = factory.GetUrlHelper(actionContext);
urlHelper.ShouldNotBeOfType<AbpCultureAwareUrlHelper>();
}
[Fact]
public void Factory_Should_Return_Default_Helper_When_RouteBasedCulture_Disabled()
{
var factory = CreateFactory(useRouteBasedCulture: false);
var httpContext = new DefaultHttpContext();
httpContext.Request.RouteValues["culture"] = "tr";
var actionContext = new ActionContext(httpContext, new RouteData(httpContext.Request.RouteValues), new Microsoft.AspNetCore.Mvc.Abstractions.ActionDescriptor());
// Even with culture in route, should not wrap when the feature is disabled
var urlHelper = factory.GetUrlHelper(actionContext);
urlHelper.ShouldNotBeOfType<AbpCultureAwareUrlHelper>();
}
private static AbpCultureRouteUrlHelperFactory CreateFactory(bool useRouteBasedCulture)
{
return new AbpCultureRouteUrlHelperFactory(
new UrlHelperFactory(),
Microsoft.Extensions.Options.Options.Create(new AbpRequestLocalizationOptions { UseRouteBasedCulture = useRouteBasedCulture }));
}
}

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

@ -0,0 +1,280 @@
using System;
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_For_Mvc_Request_Without_Culture()
{
// MVC request to /about (no culture, no HasRouteCulture cookie).
var httpContext = new DefaultHttpContext();
httpContext.Request.RouteValues["controller"] = "Home";
httpContext.Request.RouteValues["action"] = "About";
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"));
var provider = new AbpCultureMenuItemUrlProvider(
httpContextAccessor, localizationOptions, MsOptions.Create(abpLocOptions), new MenuItemCulturePrefixHelper());
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_Fallback_To_CurrentCulture_In_Blazor_Circuit()
{
// Blazor Server interactive circuit: HttpContext exists (SignalR) but has
// no route culture. Cookie was set during SSR indicating route culture was used.
var httpContext = new DefaultHttpContext();
httpContext.Request.Headers["Cookie"] = $"{AbpRequestCultureCookieHelper.HasRouteCultureCookieName}=1";
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"));
var provider = new AbpCultureMenuItemUrlProvider(
httpContextAccessor, localizationOptions, MsOptions.Create(abpLocOptions), new MenuItemCulturePrefixHelper());
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("/zh-Hans/home");
menu.Items[1].Url.ShouldBe("/zh-Hans/about");
}
[Fact]
public async Task Should_Use_CurrentCulture_Fallback_When_No_HttpContext()
{
// Simulates Blazor interactive circuit: no HttpContext, but CurrentCulture is set.
// CurrentCulture (not CurrentUICulture) is used because {culture} route segments
// represent the culture, not the UI culture.
var provider = CreateProviderWithoutHttpContext(
useRouteBasedCulture: true,
knownLanguages: new[] { "en", "zh-Hans", "tr" });
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("/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.CurrentCulture;
try
{
CultureInfo.CurrentCulture = new CultureInfo("fr");
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_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_Handle_Tilde_Slash_Urls()
{
// ~/identity/users is the pattern used by ABP module menu contributors (e.g. Identity)
var provider = CreateProvider(useRouteBasedCulture: true, cultureName: "zh-Hans");
var menu = new ApplicationMenu("TestMenu");
menu.AddItem(new ApplicationMenuItem("Users", "Users", url: "~/identity/users"));
menu.AddItem(new ApplicationMenuItem("Roles", "Roles", url: "~/identity/roles"));
await provider.HandleAsync(new MenuItemUrlProviderContext(menu));
// ~/identity/users → ~/zh-Hans/identity/users
// Blazor theme strips "~/" via TrimStart('/', '~') → "zh-Hans/identity/users"
// With <base href="/"> resolves to /zh-Hans/identity/users
menu.Items[0].Url.ShouldBe("~/zh-Hans/identity/users");
menu.Items[1].Url.ShouldBe("~/zh-Hans/identity/roles");
}
[Fact]
public async Task Should_Not_Modify_External_Urls()
{
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, new MenuItemCulturePrefixHelper());
}
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), new MenuItemCulturePrefixHelper());
}
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;
}
}

123
framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/AbpLanguagesController_Tests.cs

@ -0,0 +1,123 @@
using System;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Localization;
using Shouldly;
using Xunit;
namespace Volo.Abp.AspNetCore.Mvc.Localization;
public class AbpLanguagesController_Tests : AspNetCoreMvcTestBase
{
private const string SwitchUrl = "/Abp/Languages/Switch";
[Fact]
public async Task Should_Replace_Route_Culture_In_ReturnUrl_When_Cookie_Is_Set()
{
var response = await SendSwitchRequestAsync(
targetCulture: "zh-Hans",
returnUrl: "/en/Home/About",
currentCultureCookie: "en");
response.StatusCode.ShouldBe(HttpStatusCode.Found);
response.Headers.Location?.ToString().ShouldBe("/zh-Hans/Home/About");
}
[Fact]
public async Task Should_Replace_Route_Culture_When_Switching_Back()
{
var response = await SendSwitchRequestAsync(
targetCulture: "en",
returnUrl: "/zh-Hans/About",
currentCultureCookie: "zh-Hans");
response.StatusCode.ShouldBe(HttpStatusCode.Found);
response.Headers.Location?.ToString().ShouldBe("/en/About");
}
[Fact]
public async Task Should_Replace_Region_Culture_In_ReturnUrl()
{
var response = await SendSwitchRequestAsync(
targetCulture: "zh-Hans",
returnUrl: "/en-US/products",
currentCultureCookie: "en-US");
response.StatusCode.ShouldBe(HttpStatusCode.Found);
response.Headers.Location?.ToString().ShouldBe("/zh-Hans/products");
}
[Fact]
public async Task Should_Not_Replace_When_No_Cookie()
{
// No cookie — GetCurrentCultureFromRequestCookie returns null, no route replacement
var response = await SendSwitchRequestAsync(
targetCulture: "zh-Hans",
returnUrl: "/en/Home/About",
currentCultureCookie: null);
response.StatusCode.ShouldBe(HttpStatusCode.Found);
response.Headers.Location?.ToString().ShouldBe("/en/Home/About");
}
[Fact]
public async Task Should_Redirect_To_Root_When_ReturnUrl_Is_Empty()
{
var response = await SendSwitchRequestAsync(
targetCulture: "zh-Hans",
returnUrl: "",
currentCultureCookie: "en");
response.StatusCode.ShouldBe(HttpStatusCode.Found);
response.Headers.Location?.ToString().ShouldStartWith("/");
}
[Fact]
public async Task Should_Not_Replace_Culture_Inside_Longer_Segment_Via_Http()
{
// "en" must not corrupt "/enterprise/products"
var response = await SendSwitchRequestAsync(
targetCulture: "zh-Hans",
returnUrl: "/enterprise/products",
currentCultureCookie: "en");
response.StatusCode.ShouldBe(HttpStatusCode.Found);
response.Headers.Location?.ToString().ShouldBe("/enterprise/products");
}
[Fact]
public async Task Should_Replace_Culture_After_Tenant_Segment()
{
// Multi-tenant URL: /tenant-a/zh-Hans/About → /tenant-a/en/About
var response = await SendSwitchRequestAsync(
targetCulture: "en",
returnUrl: "/tenant-a/zh-Hans/About",
currentCultureCookie: "zh-Hans");
response.StatusCode.ShouldBe(HttpStatusCode.Found);
response.Headers.Location?.ToString().ShouldBe("/tenant-a/en/About");
}
private async Task<HttpResponseMessage> SendSwitchRequestAsync(
string targetCulture,
string returnUrl,
string? currentCultureCookie)
{
var url = $"{SwitchUrl}?culture={Uri.EscapeDataString(targetCulture)}" +
$"&uiCulture={Uri.EscapeDataString(targetCulture)}" +
$"&returnUrl={Uri.EscapeDataString(returnUrl)}";
var request = new HttpRequestMessage(HttpMethod.Get, url);
if (currentCultureCookie != null)
{
var cookieValue = CookieRequestCultureProvider.MakeCookieValue(
new RequestCulture(currentCultureCookie, currentCultureCookie));
request.Headers.Add("Cookie",
$"{CookieRequestCultureProvider.DefaultCookieName}={Uri.EscapeDataString(cookieValue)}");
}
return await Client.SendAsync(request);
}
}

259
framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/LanguageSwitchRouteCultureReplacement_Tests.cs

@ -0,0 +1,259 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Localization;
using Shouldly;
using Volo.Abp.AspNetCore.Mvc.Localization;
using Xunit;
namespace Volo.Abp.AspNetCore.Mvc.Localization;
public class LanguageSwitchRouteCultureReplacement_Tests
{
private readonly AbpAspNetCoreMvcQueryStringCultureReplacement _replacement = new();
[Fact]
public async Task Should_Replace_Route_Prefix()
{
var context = CreateContext("tr", "en", "/tr/products");
await _replacement.ReplaceAsync(context);
context.ReturnUrl.ShouldBe("/en/products");
}
[Fact]
public async Task Should_Replace_Region_Culture()
{
var context = CreateContext("en-US", "zh-Hans", "/en-US/about");
await _replacement.ReplaceAsync(context);
context.ReturnUrl.ShouldBe("/zh-Hans/about");
}
[Fact]
public async Task Should_Replace_Culture_Only_Url()
{
var context = CreateContext("tr", "en", "/tr");
await _replacement.ReplaceAsync(context);
context.ReturnUrl.ShouldBe("/en");
}
[Fact]
public async Task Should_Replace_Culture_With_Query_String()
{
var context = CreateContext("tr", "en", "/tr?returnUrl=/home");
await _replacement.ReplaceAsync(context);
context.ReturnUrl.ShouldBe("/en?returnUrl=/home");
}
[Fact]
public async Task Should_Replace_Culture_After_Tenant()
{
var context = CreateContext("zh-Hans", "en", "/tenant-a/zh-Hans/About");
await _replacement.ReplaceAsync(context);
context.ReturnUrl.ShouldBe("/tenant-a/en/About");
}
[Fact]
public async Task Should_Replace_Culture_Only_After_Tenant()
{
var context = CreateContext("tr", "en", "/tenant-a/tr");
await _replacement.ReplaceAsync(context);
context.ReturnUrl.ShouldBe("/tenant-a/en");
}
[Fact]
public async Task Should_Replace_Via_RouteData_When_No_CurrentCulture()
{
var httpContext = new DefaultHttpContext();
httpContext.Request.RouteValues["culture"] = "tr";
var context = new QueryStringCultureReplacementContext(
httpContext, new RequestCulture("en"), "/tr/products");
await _replacement.ReplaceAsync(context);
context.ReturnUrl.ShouldBe("/en/products");
}
[Fact]
public async Task Should_Not_Replace_When_No_Culture_Source()
{
var context = new QueryStringCultureReplacementContext(
new DefaultHttpContext(), new RequestCulture("en"), "/volosoft/products");
await _replacement.ReplaceAsync(context);
context.ReturnUrl.ShouldBe("/volosoft/products");
}
[Fact]
public async Task Should_Not_Replace_Culture_Inside_Longer_Segment()
{
// "en" must not match inside "enterprise"
var context = CreateContext("en", "tr", "/enterprise/products");
await _replacement.ReplaceAsync(context);
context.ReturnUrl.ShouldBe("/enterprise/products");
}
[Fact]
public async Task Should_Not_Replace_Culture_When_Culture_Is_Segment_Prefix()
{
// "fr" appears at the start of "fr-zone" but is not a complete segment
var context = CreateContext("fr", "en", "/fr-zone/about");
await _replacement.ReplaceAsync(context);
context.ReturnUrl.ShouldBe("/fr-zone/about");
}
[Fact]
public async Task Should_Replace_Culture_Before_Fragment()
{
var context = CreateContext("en", "tr", "/en#section");
await _replacement.ReplaceAsync(context);
context.ReturnUrl.ShouldBe("/tr#section");
}
[Fact]
public async Task Should_Replace_Culture_Before_Fragment_With_Path()
{
var context = CreateContext("en", "tr", "/en/about#top");
await _replacement.ReplaceAsync(context);
context.ReturnUrl.ShouldBe("/tr/about#top");
}
[Fact]
public async Task Should_Replace_Query_String_Culture()
{
var context = new QueryStringCultureReplacementContext(
new DefaultHttpContext(), new RequestCulture("en", "en"), "/home?culture=tr&ui-culture=tr");
await _replacement.ReplaceAsync(context);
context.ReturnUrl.ShouldBe("/home?culture=en&ui-culture=en");
}
[Fact]
public async Task Should_Replace_Both_Route_And_Query_String()
{
var context = CreateContext("tr", "en", "/tr/home?culture=tr&ui-culture=tr");
await _replacement.ReplaceAsync(context);
context.ReturnUrl.ShouldBe("/en/home?culture=en&ui-culture=en");
}
[Fact]
public async Task Should_Handle_Null_ReturnUrl()
{
var context = new QueryStringCultureReplacementContext(
new DefaultHttpContext(), new RequestCulture("en"), null!);
await _replacement.ReplaceAsync(context);
context.ReturnUrl.ShouldBeNull();
}
[Fact]
public async Task Should_Handle_Empty_ReturnUrl()
{
var context = new QueryStringCultureReplacementContext(
new DefaultHttpContext(), new RequestCulture("en"), "");
await _replacement.ReplaceAsync(context);
context.ReturnUrl.ShouldBe("");
}
[Fact]
public async Task Should_Not_Replace_When_CurrentCulture_Not_In_ReturnUrl()
{
// currentCulture is "fr" but returnUrl has no "/fr" segment
var context = CreateContext("fr", "en", "/about");
await _replacement.ReplaceAsync(context);
context.ReturnUrl.ShouldBe("/about");
}
[Fact]
public async Task Should_Handle_Same_Culture_Switch()
{
// Switching to the same culture — no change
var context = CreateContext("en", "en", "/en/about");
await _replacement.ReplaceAsync(context);
context.ReturnUrl.ShouldBe("/en/about");
}
[Fact]
public async Task Should_Replace_Case_Insensitive()
{
var context = CreateContext("zh-hans", "en", "/zh-Hans/about");
await _replacement.ReplaceAsync(context);
context.ReturnUrl.ShouldBe("/en/about");
}
[Fact]
public async Task Should_Handle_Whitespace_ReturnUrl()
{
var context = new QueryStringCultureReplacementContext(
new DefaultHttpContext(), new RequestCulture("en"), " ");
await _replacement.ReplaceAsync(context);
context.ReturnUrl.ShouldBe(" ");
}
[Fact]
public async Task Should_Prefer_CurrentCulture_Over_RouteData()
{
var httpContext = new DefaultHttpContext();
httpContext.Request.RouteValues["culture"] = "fr";
var context = new QueryStringCultureReplacementContext(
httpContext, new RequestCulture("en"), "/tr/about", currentCulture: "tr");
await _replacement.ReplaceAsync(context);
// Should use "tr" from CurrentCulture, not "fr" from RouteData
context.ReturnUrl.ShouldBe("/en/about");
}
[Fact]
public async Task Should_Only_Replace_Query_String_When_No_Route_Culture()
{
// No currentCulture, no RouteData — only query string replacement
var context = new QueryStringCultureReplacementContext(
new DefaultHttpContext(), new RequestCulture("en", "en"), "/?culture=tr&ui-culture=tr");
await _replacement.ReplaceAsync(context);
context.ReturnUrl.ShouldBe("/?culture=en&ui-culture=en");
}
[Fact]
public async Task Should_Replace_Culture_When_Only_Culture_Param_Present()
{
// culture= and ui-culture= are now handled independently
var context = new QueryStringCultureReplacementContext(
new DefaultHttpContext(), new RequestCulture("en"), "/?culture=tr");
await _replacement.ReplaceAsync(context);
context.ReturnUrl.ShouldBe("/?culture=en");
}
[Fact]
public async Task Should_Replace_UiCulture_When_Only_UiCulture_Param_Present()
{
var context = new QueryStringCultureReplacementContext(
new DefaultHttpContext(), new RequestCulture("en"), "/?ui-culture=tr");
await _replacement.ReplaceAsync(context);
context.ReturnUrl.ShouldBe("/?ui-culture=en");
}
[Fact]
public async Task Should_Support_Numeric_Region_Culture_Tag()
{
// es-419 (Latin America Spanish) contains a digit — previously the regex
// [A-Za-z-]+ would not match it, leaving the query string unreplaced.
var context = new QueryStringCultureReplacementContext(
new DefaultHttpContext(),
new RequestCulture("es-419", "es-419"),
"/home?culture=tr&ui-culture=tr");
await _replacement.ReplaceAsync(context);
context.ReturnUrl.ShouldBe("/home?culture=es-419&ui-culture=es-419");
}
[Fact]
public async Task Should_Replace_Only_First_Culture_Occurrence_In_Path()
{
// /en/products/en/details — the second "/en" is part of the path content,
// not a culture prefix, and must not be replaced.
var context = CreateContext("en", "tr", "/en/products/en/details");
await _replacement.ReplaceAsync(context);
context.ReturnUrl.ShouldBe("/tr/products/en/details");
}
private static QueryStringCultureReplacementContext CreateContext(
string currentCulture, string targetCulture, string returnUrl)
{
return new QueryStringCultureReplacementContext(
new DefaultHttpContext(),
new RequestCulture(targetCulture),
returnUrl,
currentCulture);
}
}

48
framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/RouteBasedCultureApiRouting_Tests.cs

@ -0,0 +1,48 @@
using System.Net;
using System.Threading.Tasks;
using Shouldly;
using Volo.Abp.AspNetCore.App;
using Xunit;
namespace Volo.Abp.AspNetCore.Mvc.Localization;
public class RouteBasedCultureApiRouting_Tests : AspNetCoreMvcTestBase
{
[Fact]
public async Task Api_Route_Should_Not_Be_Intercepted_By_Culture_Route()
{
var response = await GetResponseAsync("api/json-result-test/json-result-action");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
response.Content.Headers.ContentType!.MediaType.ShouldBe("application/json");
}
[Fact]
public async Task Controller_Should_Work_With_Culture_Prefix()
{
var result = await GetResponseAsStringAsync(
"/tr" + GetUrl<SimpleController>(nameof(SimpleController.Index)));
result.ShouldBe("Index-Result");
}
[Fact]
public async Task Controller_Should_Work_Without_Culture_Prefix()
{
var result = await GetResponseAsStringAsync(
GetUrl<SimpleController>(nameof(SimpleController.Index)));
result.ShouldBe("Index-Result");
}
[Fact]
public async Task RazorPage_Should_Work_With_Culture_Prefix()
{
var response = await GetResponseAsync("/tr/Auditing/AuditTestPage");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
}
[Fact]
public async Task RazorPage_Should_Work_Without_Culture_Prefix()
{
var response = await GetResponseAsync("/Auditing/AuditTestPage");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
}
}

103
framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/RouteBasedCultureNavigationHelper_Tests.cs

@ -0,0 +1,103 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
using Shouldly;
using Volo.Abp.AspNetCore.Components.WebAssembly;
using Volo.Abp.Localization;
using Xunit;
namespace Volo.Abp.AspNetCore.Mvc.Localization;
public class RouteBasedCultureNavigationHelper_Tests
{
private static readonly IEnumerable<LanguageInfo> AllLanguages = new[]
{
new LanguageInfo("en"),
new LanguageInfo("tr"),
new LanguageInfo("zh-Hans"),
};
private readonly RouteBasedCultureNavigationHelper _helper = new();
[Fact]
public async Task Should_Replace_Culture_In_Simple_Path()
{
var nav = new TestNavigationManager("https://example.com/", "https://example.com/tr/home");
await _helper.NavigateToNewCultureAsync(nav, new LanguageInfo("en"), AllLanguages);
nav.LastNavigatedUri.ShouldBe("https://example.com/en/home");
}
[Fact]
public async Task Should_Replace_Culture_When_No_Path_After_Culture()
{
var nav = new TestNavigationManager("https://example.com/", "https://example.com/tr");
await _helper.NavigateToNewCultureAsync(nav, new LanguageInfo("en"), AllLanguages);
nav.LastNavigatedUri.ShouldBe("https://example.com/en");
}
[Fact]
public async Task Should_Replace_Culture_When_Query_String_Follows_Culture_Directly()
{
// Regression: "tr?x=1" was being treated as a single segment "tr?x=1"
// instead of culture="tr" + suffix="?x=1".
var nav = new TestNavigationManager("https://example.com/", "https://example.com/tr?x=1");
await _helper.NavigateToNewCultureAsync(nav, new LanguageInfo("en"), AllLanguages);
nav.LastNavigatedUri.ShouldBe("https://example.com/en?x=1");
}
[Fact]
public async Task Should_Replace_Culture_When_Fragment_Follows_Culture_Directly()
{
var nav = new TestNavigationManager("https://example.com/", "https://example.com/tr#section");
await _helper.NavigateToNewCultureAsync(nav, new LanguageInfo("en"), AllLanguages);
nav.LastNavigatedUri.ShouldBe("https://example.com/en#section");
}
[Fact]
public async Task Should_Replace_Culture_Preserving_Path_Query_And_Fragment()
{
var nav = new TestNavigationManager("https://example.com/", "https://example.com/tr/about?ref=main#top");
await _helper.NavigateToNewCultureAsync(nav, new LanguageInfo("zh-Hans"), AllLanguages);
nav.LastNavigatedUri.ShouldBe("https://example.com/zh-Hans/about?ref=main#top");
}
[Fact]
public async Task Should_Prepend_Culture_When_No_Existing_Culture_Prefix()
{
var nav = new TestNavigationManager("https://example.com/", "https://example.com/identity/users");
await _helper.NavigateToNewCultureAsync(nav, new LanguageInfo("zh-Hans"), AllLanguages);
nav.LastNavigatedUri.ShouldBe("https://example.com/zh-Hans/identity/users");
}
[Fact]
public async Task Should_Prepend_Culture_When_At_Root()
{
var nav = new TestNavigationManager("https://example.com/", "https://example.com/");
await _helper.NavigateToNewCultureAsync(nav, new LanguageInfo("tr"), AllLanguages);
nav.LastNavigatedUri.ShouldBe("https://example.com/tr/");
}
[Fact]
public async Task Should_Not_Navigate_When_Target_Culture_Matches_Current()
{
var nav = new TestNavigationManager("https://example.com/", "https://example.com/tr/home");
await _helper.NavigateToNewCultureAsync(nav, new LanguageInfo("tr"), AllLanguages);
// Already on /tr/home — no navigation should occur
nav.LastNavigatedUri.ShouldBeNull();
}
private sealed class TestNavigationManager : NavigationManager
{
public string? LastNavigatedUri { get; private set; }
public TestNavigationManager(string baseUri, string uri)
{
Initialize(baseUri, uri);
}
protected override void NavigateToCore(string uri, bool forceLoad)
{
LastNavigatedUri = uri;
}
}
}

156
framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/RouteBasedCultureUrlHelper_Tests.cs

@ -0,0 +1,156 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Threading.Tasks;
using NSubstitute;
using Shouldly;
using Volo.Abp.AspNetCore.Components.WebAssembly;
using Volo.Abp.AspNetCore.Mvc.ApplicationConfigurations;
using Volo.Abp.AspNetCore.Mvc.Client;
using Volo.Abp.Localization;
using Xunit;
namespace Volo.Abp.AspNetCore.Mvc.Localization;
public class RouteBasedCultureUrlHelper_Tests
{
private readonly ICachedApplicationConfigurationClient _configClient;
private readonly RouteBasedCultureUrlHelper _helper;
private readonly ApplicationConfigurationDto _config;
public RouteBasedCultureUrlHelper_Tests()
{
_config = new ApplicationConfigurationDto
{
Localization = new ApplicationLocalizationConfigurationDto
{
UseRouteBasedCulture = true,
Languages = new List<LanguageInfo>
{
new LanguageInfo("en"),
new LanguageInfo("zh-Hans"),
new LanguageInfo("tr"),
new LanguageInfo("es-419"),
}
}
};
_configClient = Substitute.For<ICachedApplicationConfigurationClient>();
_configClient.GetAsync().Returns(_config);
_helper = new RouteBasedCultureUrlHelper(_configClient);
}
[Theory]
[InlineData("https://auth-server.example.com/connect/authorize")]
[InlineData("http://example.com/login")]
public async Task Should_Not_Modify_Absolute_Urls(string url)
{
using var _ = CultureScope("zh-Hans");
var result = await _helper.PrependCulturePrefixAsync(url);
result.ShouldBe(url);
}
[Fact]
public async Task Should_Not_Modify_Protocol_Relative_Url()
{
using var _ = CultureScope("zh-Hans");
var result = await _helper.PrependCulturePrefixAsync("//cdn.example.com/asset.js");
result.ShouldBe("//cdn.example.com/asset.js");
}
[Fact]
public async Task Should_Prepend_Culture_To_Root_Relative_Url()
{
using var _ = CultureScope("zh-Hans");
var result = await _helper.PrependCulturePrefixAsync("/account/manage-profile");
result.ShouldBe("/zh-Hans/account/manage-profile");
}
[Fact]
public async Task Should_Prepend_Culture_To_Tilde_Slash_Url()
{
using var _ = CultureScope("tr");
var result = await _helper.PrependCulturePrefixAsync("~/account/manage-profile");
result.ShouldBe("~/tr/account/manage-profile");
}
[Fact]
public async Task Should_Prepend_Culture_To_Bare_Relative_Url()
{
// Default auth URLs like "authentication/login" have no leading slash.
using var _ = CultureScope("zh-Hans");
var result = await _helper.PrependCulturePrefixAsync("authentication/login");
result.ShouldBe("zh-Hans/authentication/login");
}
[Fact]
public async Task Should_Not_Modify_Url_When_Feature_Disabled()
{
_config.Localization.UseRouteBasedCulture = false;
using var _ = CultureScope("zh-Hans");
var result = await _helper.PrependCulturePrefixAsync("/home");
result.ShouldBe("/home");
}
[Fact]
public async Task Should_Not_Modify_Url_When_Culture_Not_In_Language_List()
{
using var _ = CultureScope("fr");
var result = await _helper.PrependCulturePrefixAsync("/home");
result.ShouldBe("/home");
}
[Fact]
public async Task Should_Return_Empty_String_Unchanged()
{
var result = await _helper.PrependCulturePrefixAsync(string.Empty);
result.ShouldBe(string.Empty);
}
[Fact]
public async Task Should_Support_Numeric_Region_Culture_Tag()
{
using var _ = CultureScope("es-419");
var result = await _helper.PrependCulturePrefixAsync("/home");
result.ShouldBe("/es-419/home");
}
[Fact]
public async Task Should_Be_Idempotent_On_Root_Relative_Url()
{
using var _ = CultureScope("zh-Hans");
var result = await _helper.PrependCulturePrefixAsync("/zh-Hans/account/manage-profile");
result.ShouldBe("/zh-Hans/account/manage-profile");
}
[Fact]
public async Task Should_Be_Idempotent_On_Tilde_Slash_Url()
{
using var _ = CultureScope("tr");
var result = await _helper.PrependCulturePrefixAsync("~/tr/account/manage-profile");
result.ShouldBe("~/tr/account/manage-profile");
}
[Fact]
public async Task Should_Be_Idempotent_On_Bare_Relative_Url()
{
using var _ = CultureScope("zh-Hans");
var result = await _helper.PrependCulturePrefixAsync("zh-Hans/authentication/login");
result.ShouldBe("zh-Hans/authentication/login");
}
private static IDisposable CultureScope(string cultureName)
{
var previous = CultureInfo.CurrentCulture;
CultureInfo.CurrentCulture = new CultureInfo(cultureName);
return new DelegateDisposable(() => CultureInfo.CurrentCulture = previous);
}
private sealed class DelegateDisposable : IDisposable
{
private readonly System.Action _onDispose;
public DelegateDisposable(System.Action onDispose) => _onDispose = onDispose;
public void Dispose() => _onDispose();
}
}

53
framework/test/Volo.Abp.AspNetCore.Tests/Volo/Abp/AspNetCore/Localization/RouteBasedCultureTestModule.cs

@ -0,0 +1,53 @@
using System.Globalization;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.RequestLocalization;
using Microsoft.AspNetCore.Routing;
using Volo.Abp.Localization;
using Volo.Abp.Modularity;
namespace Volo.Abp.AspNetCore.Localization;
[DependsOn(typeof(AbpAspNetCoreTestModule))]
public class RouteBasedCultureTestModule : AbpModule
{
public override void ConfigureServices(ServiceConfigurationContext context)
{
Configure<AbpRequestLocalizationOptions>(options =>
{
options.UseRouteBasedCulture = true;
});
Configure<AbpLocalizationOptions>(options =>
{
options.Languages.Add(new LanguageInfo("en", "en", "English"));
options.Languages.Add(new LanguageInfo("tr", "tr", "Türkçe"));
});
}
public override void OnApplicationInitialization(ApplicationInitializationContext context)
{
var app = context.GetApplicationBuilder();
app.UseRouting();
app.UseAbpRequestLocalization();
app.UseEndpoints(endpoints =>
{
endpoints.MapGet("{culture}/culture", async ctx =>
{
await ctx.Response.WriteAsync(CultureInfo.CurrentCulture.Name);
});
endpoints.MapGet("culture", async ctx =>
{
await ctx.Response.WriteAsync(CultureInfo.CurrentCulture.Name);
});
endpoints.MapGet("api/data", async ctx =>
{
await ctx.Response.WriteAsync(CultureInfo.CurrentCulture.Name);
});
});
}
}

75
framework/test/Volo.Abp.AspNetCore.Tests/Volo/Abp/AspNetCore/Localization/RouteBasedCulture_Tests.cs

@ -0,0 +1,75 @@
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Localization;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Shouldly;
using Xunit;
namespace Volo.Abp.AspNetCore.Localization;
public class RouteBasedCulture_Tests : IAsyncLifetime
{
private WebApplication _app;
private HttpClient _client;
public async Task InitializeAsync()
{
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseTestServer();
builder.Host.UseAutofac();
await builder.AddApplicationAsync<RouteBasedCultureTestModule>();
_app = builder.Build();
await _app.InitializeApplicationAsync();
await _app.StartAsync();
_client = ((IHost)_app).GetTestClient();
}
public async Task DisposeAsync()
{
_client?.Dispose();
if (_app != null)
{
await _app.StopAsync();
await _app.DisposeAsync();
}
}
[Fact]
public async Task RouteBasedCulture_SetsCultureCorrectly()
{
var response = await _client!.GetStringAsync("/tr/culture");
response.ShouldBe("tr");
}
[Fact]
public async Task RouteBasedCulture_SetsCookieOnResponse()
{
var response = await _client!.GetAsync("/tr/culture");
response.StatusCode.ShouldBe(HttpStatusCode.OK);
response.Headers.Contains("Set-Cookie").ShouldBeTrue();
var cookieValue = string.Join(";", response.Headers.GetValues("Set-Cookie"));
cookieValue.ShouldContain(CookieRequestCultureProvider.DefaultCookieName);
cookieValue.ShouldContain("tr");
}
[Fact]
public async Task RouteBasedCulture_InvalidCultureCodeFallsThrough()
{
// "xyz1234" is not a valid culture - should fall through to the default culture "en"
var response = await _client!.GetStringAsync("/xyz1234/culture");
response.ShouldBe("en");
}
[Fact]
public async Task RouteBasedCulture_ApiRoutesNotAffected()
{
// /api/data has no {culture} prefix route - falls through to the default culture "en"
var response = await _client!.GetStringAsync("/api/data");
response.ShouldBe("en");
}
}

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

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

1
modules/account/src/Volo.Abp.Account.Blazor/Pages/Account/AccountManage.razor

@ -1,4 +1,5 @@
@page "/account/manage-profile"
@page "/{culture}/account/manage-profile"
@using Microsoft.AspNetCore.Components.Forms
@using Volo.Abp.Account.Localization
@using Volo.Abp.AspNetCore.Components.Web

3
modules/account/src/Volo.Abp.Account.Blazor/Pages/Account/AccountManage.razor.cs

@ -8,6 +8,9 @@ namespace Volo.Abp.Account.Blazor.Pages.Account;
public partial class AccountManage
{
[Parameter]
public string? Culture { get; set; }
[Inject] protected IProfileAppService ProfileAppService { get; set; }
[Inject] protected IUiMessageService UiMessageService { get; set; }

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

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

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

@ -2,6 +2,8 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Routing;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.RequestLocalization;
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 = AbpRequestCultureCookieHelper.GetRouteCulture(HttpContextAccessor.HttpContext);
return string.IsNullOrEmpty(culture) ? "Account/Login" : $"{culture}/Account/Login";
}
protected virtual void OnLocationChanged(object sender, LocationChangedEventArgs e)
{
InvokeAsync(StateHasChanged);

4
modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/BasicThemeToolbarContributor.cs

@ -14,8 +14,6 @@ public class BasicThemeToolbarContributor : IToolbarContributor
{
if (context.Toolbar.Name == StandardToolbars.Main)
{
context.Toolbar.Items.Add(new ToolbarItem(typeof(LanguageSwitch)));
//TODO: Can we find a different way to understand if authentication was configured or not?
var authenticationStateProvider = context.ServiceProvider
.GetService<AuthenticationStateProvider>();
@ -24,6 +22,8 @@ public class BasicThemeToolbarContributor : IToolbarContributor
{
context.Toolbar.Items.Add(new ToolbarItem(typeof(LoginDisplay)));
}
context.Toolbar.Items.Add(new ToolbarItem(typeof(LanguageSwitch)));
}
return Task.CompletedTask;

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

@ -1,10 +1,15 @@
@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.Components.WebAssembly
@using Volo.Abp.AspNetCore.Mvc.Client
@inject ILanguageProvider LanguageProvider
@inject IJSRuntime JsRuntime
@inject ICookieService CookieService
@inject NavigationManager NavigationManager
@inject ICachedApplicationConfigurationClient ConfigurationClient
@inject IRouteBasedCultureNavigationHelper CultureNavigationHelper
@if (_otherLanguages != null && _otherLanguages.Any())
{
<BarDropdown RightAligned="true">
@ -22,9 +27,13 @@
@code {
private IReadOnlyList<LanguageInfo> _otherLanguages;
private LanguageInfo _currentLanguage;
private bool _useRouteBasedCulture;
protected override async Task OnInitializedAsync()
{
var config = await ConfigurationClient.GetAsync();
_useRouteBasedCulture = config.Localization.UseRouteBasedCulture;
var selectedLanguageName = await JsRuntime.InvokeAsync<string>(
"localStorage.getItem",
"Abp.SelectedLanguage"
@ -57,6 +66,13 @@
private async Task ChangeLanguageAsync(LanguageInfo language)
{
if (_useRouteBasedCulture)
{
await CultureNavigationHelper.NavigateToNewCultureAsync(
NavigationManager, language, _otherLanguages.Append(_currentLanguage));
return;
}
await JsRuntime.InvokeVoidAsync(
"localStorage.setItem",
"Abp.SelectedLanguage",

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

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

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

@ -1,10 +1,11 @@
using System;
using System;
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.Components.WebAssembly;
using Volo.Abp.UI.Navigation;
namespace Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme.Themes.Basic;
@ -17,10 +18,20 @@ public partial class LoginDisplay : IDisposable
[Inject]
protected ApplicationConfigurationChangedService ApplicationConfigurationChangedService { get; set; }
[Inject]
protected IRouteBasedCultureUrlHelper CultureUrlHelper { get; set; }
protected ApplicationMenu Menu { get; set; }
protected string LoginUrl { get; set; } = string.Empty;
protected string LogoutUrl { get; set; } = string.Empty;
protected async override Task OnInitializedAsync()
{
LoginUrl = await CultureUrlHelper.PrependCulturePrefixAsync(AuthenticationOptions.Value.LoginUrl);
LogoutUrl = await CultureUrlHelper.PrependCulturePrefixAsync(AuthenticationOptions.Value.LogoutUrl);
Menu = await MenuManager.GetAsync(StandardMenus.User);
Navigation.LocationChanged += OnLocationChanged;
@ -35,6 +46,8 @@ public partial class LoginDisplay : IDisposable
private async void ApplicationConfigurationChanged()
{
LoginUrl = await CultureUrlHelper.PrependCulturePrefixAsync(AuthenticationOptions.Value.LoginUrl);
LogoutUrl = await CultureUrlHelper.PrependCulturePrefixAsync(AuthenticationOptions.Value.LogoutUrl);
Menu = await MenuManager.GetAsync(StandardMenus.User);
await InvokeAsync(StateHasChanged);
}
@ -47,9 +60,11 @@ public partial class LoginDisplay : IDisposable
private async Task NavigateToAsync(string uri, string target = null)
{
uri = uri?.TrimStart('~', '/') ?? uri;
if (target == "_blank")
{
await JsRuntime.InvokeVoidAsync("open", uri, target);
await JsRuntime.InvokeVoidAsync("open", Navigation.ToAbsoluteUri(uri).ToString(), target);
}
else
{
@ -57,15 +72,15 @@ public partial class LoginDisplay : IDisposable
}
}
private void BeginSignOut()
private async Task BeginSignOut()
{
if (AbpAspNetCoreComponentsWebOptions.Value.IsBlazorWebApp)
{
Navigation.NavigateTo(AuthenticationOptions.Value.LogoutUrl, forceLoad: true);
Navigation.NavigateTo(LogoutUrl, forceLoad: true);
}
else
{
Navigation.NavigateToLogout(AuthenticationOptions.Value.LogoutUrl);
Navigation.NavigateToLogout(LogoutUrl);
}
}
}

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

@ -1,25 +1,22 @@
@inject NavigationManager Navigation
@using Volo.Abp.DependencyInjection
@using Volo.Abp.AspNetCore.Components.Web.BasicTheme.Themes.Basic
@using Microsoft.AspNetCore.Components.WebAssembly.Authentication
@using Volo.Abp.AspNetCore.Components.WebAssembly.Theming
@using Microsoft.Extensions.Options
@using Volo.Abp.AspNetCore.Components.WebAssembly
@using Volo.Abp.AspNetCore.Components.Web
@inherits RedirectToLogin
@attribute [ExposeServices(typeof(RedirectToLogin))]
@attribute [Dependency(ReplaceServices = true)]
@inject IOptions<AuthenticationOptions> AuthenticationOptions
@inject NavigationManager Navigation
@inject IOptions<AuthenticationOptions> AuthOptions
@inject IRouteBasedCultureUrlHelper CultureUrlHelper
@inject IOptions<AbpAspNetCoreComponentsWebOptions> AbpAspNetCoreComponentsWebOptions
@code {
protected override void OnInitialized()
protected override void OnInitialized() { }
protected override Task OnInitializedAsync()
{
if (AbpAspNetCoreComponentsWebOptions.Value.IsBlazorWebApp)
{
Navigation.NavigateTo(AuthenticationOptions.Value.LoginUrl, forceLoad: true);
}
else
{
Navigation.NavigateToLogin(AuthenticationOptions.Value.LoginUrl);
}
return CultureAwareRedirectToLoginHelper.RedirectAsync(Navigation, AuthOptions.Value.LoginUrl, CultureUrlHelper, AbpAspNetCoreComponentsWebOptions);
}
}

1
modules/identity/src/Volo.Abp.Identity.Blazor/Pages/Identity/RoleManagement.razor

@ -1,4 +1,5 @@
@page "/identity/roles"
@page "/{culture}/identity/roles"
@attribute [Authorize(IdentityPermissions.Roles.Default)]
@using Volo.Abp.Identity
@using Microsoft.AspNetCore.Authorization

4
modules/identity/src/Volo.Abp.Identity.Blazor/Pages/Identity/RoleManagement.razor.cs

@ -1,6 +1,7 @@
using System.Threading.Tasks;
using Blazorise;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
@ -16,6 +17,9 @@ namespace Volo.Abp.Identity.Blazor.Pages.Identity;
public partial class RoleManagement
{
[Parameter]
public string? Culture { get; set; }
protected const string PermissionProviderName = "R";
protected PermissionManagementModal PermissionManagementModal;

1
modules/identity/src/Volo.Abp.Identity.Blazor/Pages/Identity/UserManagement.razor

@ -1,4 +1,5 @@
@page "/identity/users"
@page "/{culture}/identity/users"
@attribute [Authorize(IdentityPermissions.Users.Default)]
@using Microsoft.AspNetCore.Authorization
@using Volo.Abp.PermissionManagement.Blazor.Components

3
modules/identity/src/Volo.Abp.Identity.Blazor/Pages/Identity/UserManagement.razor.cs

@ -18,6 +18,9 @@ namespace Volo.Abp.Identity.Blazor.Pages.Identity;
public partial class UserManagement
{
[Parameter]
public string? Culture { get; set; }
protected const string PermissionProviderName = "U";
protected const string DefaultSelectedTab = "UserInformations";

1
modules/setting-management/src/Volo.Abp.SettingManagement.Blazor/Pages/SettingManagement/SettingManagement.razor

@ -1,4 +1,5 @@
@page "/setting-management"
@page "/{culture}/setting-management"
@using Microsoft.AspNetCore.Authorization
@using Volo.Abp.AspNetCore.Components.Web.Theming.Layout
@using Volo.Abp.Features

3
modules/setting-management/src/Volo.Abp.SettingManagement.Blazor/Pages/SettingManagement/SettingManagement.razor.cs

@ -12,6 +12,9 @@ namespace Volo.Abp.SettingManagement.Blazor.Pages.SettingManagement;
public partial class SettingManagement
{
[Parameter]
public string? Culture { get; set; }
[Inject]
protected IServiceProvider ServiceProvider { get; set; }

1
modules/tenant-management/src/Volo.Abp.TenantManagement.Blazor/Pages/TenantManagement/TenantManagement.razor

@ -1,4 +1,5 @@
@page "/tenant-management/tenants"
@page "/{culture}/tenant-management/tenants"
@attribute [Authorize(TenantManagementPermissions.Tenants.Default)]
@using Microsoft.AspNetCore.Authorization
@using Volo.Abp.FeatureManagement.Blazor.Components

4
modules/tenant-management/src/Volo.Abp.TenantManagement.Blazor/Pages/TenantManagement/TenantManagement.razor.cs

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Threading.Tasks;
using Blazorise;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Components;
using Volo.Abp.AspNetCore.Components.Web.Extensibility.EntityActions;
using Volo.Abp.AspNetCore.Components.Web.Extensibility.TableColumns;
using Volo.Abp.AspNetCore.Components.Web.Theming.PageToolbars;
@ -14,6 +15,9 @@ namespace Volo.Abp.TenantManagement.Blazor.Pages.TenantManagement;
public partial class TenantManagement
{
[Parameter]
public string? Culture { get; set; }
protected const string FeatureProviderName = "T";
protected bool HasManageFeaturesPermission;

6
npm/ng-packs/packages/core/src/lib/components/dynamic-layout.component.ts

@ -14,7 +14,8 @@ import { ReplaceableComponentsService } from '../services/replaceable-components
import { RouterEvents } from '../services/router-events.service';
import { RoutesService } from '../services/routes.service';
import { SubscriptionService } from '../services/subscription.service';
import { findRoute, getRoutePath } from '../utils/route-utils';
import { RouteBasedCultureUrlService } from '../services/route-based-culture-url.service';
import { findRoute } from '../utils/route-utils';
import { TreeNode } from '../utils/tree-utils';
import { DYNAMIC_LAYOUTS_TOKEN } from '../tokens/dynamic-layout.token';
import { EnvironmentService } from '../services';
@ -46,6 +47,7 @@ export class DynamicLayoutComponent {
protected readonly subscription = inject(SubscriptionService);
protected readonly routerEvents = inject(RouterEvents);
protected readonly environment = inject(EnvironmentService);
protected readonly routeCultureUrl = inject(RouteBasedCultureUrlService);
constructor() {
const dynamicLayoutComponent = inject(DynamicLayoutComponent, { optional: true, skipSelf: true });
@ -85,7 +87,7 @@ export class DynamicLayoutComponent {
const routeData = this.route.snapshot.data || {};
let expectedLayout = routeData['layout'] as eLayoutType;
let node = findRoute(this.routes, getRoutePath(this.router));
let node = findRoute(this.routes, this.routeCultureUrl.getRoutePathForMatching(this.router));
node = { parent: node } as TreeNode<ABP.Route>;
while (node.parent) {

19
npm/ng-packs/packages/core/src/lib/guards/permission.guard.ts

@ -10,8 +10,14 @@ import { HttpErrorResponse } from '@angular/common/http';
import { Observable, of } from 'rxjs';
import { filter, map, switchMap, take } from 'rxjs/operators';
import { AuthService, IAbpGuard } from '../abstracts';
import { findRoute, getRoutePath } from '../utils/route-utils';
import { RoutesService, PermissionService, HttpErrorReporterService, ConfigStateService } from '../services';
import { findRoute } from '../utils/route-utils';
import {
RoutesService,
PermissionService,
HttpErrorReporterService,
ConfigStateService,
RouteBasedCultureUrlService,
} from '../services';
import { isPlatformServer } from '@angular/common';
/**
* @deprecated Use `permissionGuard` *function* instead.
@ -26,12 +32,16 @@ export class PermissionGuard implements IAbpGuard {
protected readonly permissionService = inject(PermissionService);
protected readonly httpErrorReporter = inject(HttpErrorReporterService);
protected readonly configStateService = inject(ConfigStateService);
protected readonly routeCultureUrl = inject(RouteBasedCultureUrlService);
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable<boolean | UrlTree> {
let { requiredPolicy } = route.data || {};
if (!requiredPolicy) {
const routeFound = findRoute(this.routesService, getRoutePath(this.router, state.url));
const routeFound = findRoute(
this.routesService,
this.routeCultureUrl.getRoutePathForMatching(this.router, state.url),
);
requiredPolicy = routeFound?.requiredPolicy;
}
@ -70,12 +80,13 @@ export const permissionGuard: CanActivateFn = (
const permissionService = inject(PermissionService);
const httpErrorReporter = inject(HttpErrorReporterService);
const configStateService = inject(ConfigStateService);
const routeCultureUrl = inject(RouteBasedCultureUrlService);
const platformId = inject(PLATFORM_ID);
let { requiredPolicy } = route.data || {};
if (!requiredPolicy) {
const routeFound = findRoute(routesService, getRoutePath(router, state.url));
const routeFound = findRoute(routesService, routeCultureUrl.getRoutePathForMatching(router, state.url));
requiredPolicy = routeFound?.requiredPolicy;
}

1
npm/ng-packs/packages/core/src/lib/pipes/index.ts

@ -8,4 +8,5 @@ export * from './short-date-time.pipe';
export * from './utc-to-local.pipe';
export * from './async-localization.pipe';
export * from './lazy-localization.pipe';
export * from './route-culture-url.pipe';
export * from './html-encode.pipe';

18
npm/ng-packs/packages/core/src/lib/pipes/route-culture-url.pipe.ts

@ -0,0 +1,18 @@
import { Pipe, PipeTransform, inject } from '@angular/core';
import { RouteBasedCultureUrlService } from '../services/route-based-culture-url.service';
/**
* Prefixes menu/navigation paths with the current culture when route-based culture is enabled.
* Impure so links update after language changes.
*/
@Pipe({
name: 'abpRouteCultureUrl',
pure: false,
})
export class AbpRouteCultureUrlPipe implements PipeTransform {
private readonly url = inject(RouteBasedCultureUrlService);
transform(path: string | undefined | null): string | undefined | null {
return this.url.prefixPathWithCulture(path);
}
}

1
npm/ng-packs/packages/core/src/lib/proxy/volo/abp/asp-net-core/mvc/application-configurations/models.ts

@ -42,6 +42,7 @@ export interface ApplicationLocalizationConfigurationDto {
defaultResourceName?: string;
languagesMap: Record<string, NameValue[]>;
languageFilesMap: Record<string, NameValue[]>;
useRouteBasedCulture: boolean;
}
export interface ApplicationLocalizationDto {

2
npm/ng-packs/packages/core/src/lib/services/index.ts

@ -16,6 +16,8 @@ export * from './resource-wait.service';
export * from './rest.service';
export * from './router-events.service';
export * from './router-wait.service';
export * from './route-based-culture.service';
export * from './route-based-culture-url.service';
export * from './routes.service';
export * from './session-state.service';
export * from './subscription.service';

170
npm/ng-packs/packages/core/src/lib/services/route-based-culture-url.service.ts

@ -0,0 +1,170 @@
import { Location } from '@angular/common';
import { isPlatformBrowser } from '@angular/common';
import { Injectable, PLATFORM_ID, inject } from '@angular/core';
import { Router } from '@angular/router';
import { LanguageInfo } from '../proxy/volo/abp/localization/models';
import {
findMatchingCultureName,
getFirstPathSegment,
stripCultureSegmentFromPath,
} from '../utils/route-based-culture.utils';
import { getRoutePath } from '../utils/route-utils';
import { ConfigStateService } from './config-state.service';
import { SessionStateService } from './session-state.service';
/**
* URL helpers for route-based culture: prefix menu links, strip culture for route matching,
* and navigate when the user picks a language so the URL stays in sync with the session.
*/
@Injectable({
providedIn: 'root',
})
export class RouteBasedCultureUrlService {
private readonly router = inject(Router);
private readonly location = inject(Location);
private readonly configState = inject(ConfigStateService);
private readonly sessionState = inject(SessionStateService);
private readonly platformId = inject(PLATFORM_ID);
/** Cached from localization config; refreshed when application configuration updates. */
private useRouteBasedCulture = false;
private languages: LanguageInfo[] | undefined;
constructor() {
this.refreshRouteCultureCache();
this.configState.getAll$().subscribe(() => this.refreshRouteCultureCache());
}
private refreshRouteCultureCache(): void {
const loc = this.configState.getOne('localization');
if (!loc) {
this.useRouteBasedCulture = false;
this.languages = undefined;
return;
}
this.useRouteBasedCulture = !!loc.useRouteBasedCulture;
this.languages = loc.languages as LanguageInfo[] | undefined;
}
private isRouteBasedCultureEnabled(): boolean {
return this.useRouteBasedCulture;
}
/**
* Same as {@link getRoutePath} but removes the leading culture segment when route-based culture is enabled,
* so paths match `RoutesService` entries (e.g. `/identity/users`).
*/
getRoutePathForMatching(router: Router, url = router.url): string {
const raw = getRoutePath(router, url);
return this.stripCulturePrefixIfEnabled(raw);
}
/**
* Strips the leading culture segment when it matches a configured language and route-based culture is enabled.
* Use for menu active state, breadcrumbs, and any comparison between `router.url` and `RoutesService` paths.
*/
stripCulturePrefixIfEnabled(path: string): string {
if (!this.isRouteBasedCultureEnabled() || !path) {
return path;
}
return this.stripCulturePrefix(path);
}
/**
* Alias for {@link stripCulturePrefixIfEnabled}: normalizes the current URL for **menu highlighting**
* when menu `link` values omit the culture segment (e.g. Lepton `NavbarRoutesComponent`, `NavbarService.getRouteItem`).
*/
normalizeForMenuMatch(path: string): string {
return this.stripCulturePrefixIfEnabled(path);
}
/**
* Removes the first segment when it is a known UI culture (for matching and normalization).
*/
stripCulturePrefix(path: string): string {
return stripCultureSegmentFromPath(path, this.languages);
}
/**
* Prefixes an app path with the current session culture when route-based culture is enabled
* (e.g. `/identity/users` `/en/identity/users`). Use for `routerLink`, `navigateByUrl`, etc.
*/
prefixPathWithCulture(path: string | undefined | null): string | undefined | null {
if (path == null || path === '') {
return path;
}
if (!this.isRouteBasedCultureEnabled()) {
return path;
}
if (/^https?:\/\//i.test(path)) {
return path;
}
const lang = this.sessionState.getLanguage();
if (!lang) {
return path;
}
const stripped = this.stripCulturePrefix(path);
const normalized = stripped.startsWith('/') ? stripped : '/' + stripped;
const suffix = normalized === '/' ? '' : normalized;
return `/${lang}${suffix}`;
}
/**
* Rewrites the current URL so the first segment is {@link cultureName} (or prepends it).
* Call this when the user selects a language in the UI instead of only {@link SessionStateService.setLanguage}
* so the address bar stays aligned with the session.
*/
navigateToUrlWithCulture(cultureName: string): Promise<boolean> | void {
if (!cultureName || !isPlatformBrowser(this.platformId)) {
return Promise.resolve(false);
}
if (!this.isRouteBasedCultureEnabled()) {
this.sessionState.setLanguage(cultureName);
return Promise.resolve(true);
}
const path = this.location.path();
const newPath = this.rewritePathToCulture(path, cultureName);
return this.router.navigateByUrl(newPath);
}
/**
* Preferred entry point for language pickers (e.g. Lepton toolbar): same as {@link navigateToUrlWithCulture}.
*/
applyLanguageSelection(cultureName: string): Promise<boolean> | void {
return this.navigateToUrlWithCulture(cultureName);
}
/**
* Builds a new path (including query/hash) with the given culture as the first segment,
* replacing the culture segment when one is already present.
*/
rewritePathToCulture(urlPath: string, newCulture: string): string {
const languages = this.languages;
const pathEnd = urlPath.search(/[?#]/);
const pathOnly = pathEnd >= 0 ? urlPath.slice(0, pathEnd) : urlPath;
const suffix = pathEnd >= 0 ? urlPath.slice(pathEnd) : '';
const first = getFirstPathSegment(pathOnly);
const firstIsCulture = !!findMatchingCultureName(first, languages);
if (firstIsCulture) {
const segments = pathOnly.split('/').filter(s => s.length > 0);
if (segments.length === 0) {
return `/${newCulture}${suffix}`;
}
segments[0] = newCulture;
const joined = '/' + segments.join('/');
return joined + suffix;
}
const normalized = pathOnly.startsWith('/') ? pathOnly : '/' + pathOnly;
const rest = normalized === '/' ? '' : normalized;
return `/${newCulture}${rest}${suffix}`;
}
}

59
npm/ng-packs/packages/core/src/lib/services/route-based-culture.service.ts

@ -0,0 +1,59 @@
import { isPlatformBrowser } from '@angular/common';
import { Injectable, PLATFORM_ID, inject } from '@angular/core';
import { NavigationEnd, Router } from '@angular/router';
import { filter } from 'rxjs/operators';
import { LanguageInfo } from '../proxy/volo/abp/localization/models';
import { findMatchingCultureName, getFirstPathSegment } from '../utils/route-based-culture.utils';
import { ConfigStateService } from './config-state.service';
import { SessionStateService } from './session-state.service';
/**
* When the backend enables URL-based localization (`localization.useRouteBasedCulture` from application configuration),
* keeps session language in sync with the first URL path segment (e.g. /en/..., /tr-TR/...).
* Works with nested routes because only the leading segment is interpreted as culture.
*/
@Injectable({
providedIn: 'root',
})
export class RouteBasedCultureService {
private readonly router = inject(Router);
private readonly configState = inject(ConfigStateService);
private readonly sessionState = inject(SessionStateService);
private readonly platformId = inject(PLATFORM_ID);
protected readonly localization = this.configState.getOne('localization');
constructor() {
if (!isPlatformBrowser(this.platformId)) {
return;
}
if (!this.localization?.useRouteBasedCulture) {
return;
}
this.router.events
.pipe(filter((e): e is NavigationEnd => e instanceof NavigationEnd))
.subscribe(() => this.syncLanguageFromUrl());
}
/**
* Reads the culture from the current URL and updates session language when it matches a configured language.
* @param pathOverride Optional path (e.g. from `Location.path()` during app bootstrap before navigation settles).
*/
syncLanguageFromUrl(pathOverride?: string): void {
if (!isPlatformBrowser(this.platformId)) {
return;
}
const languages = this.localization?.languages as LanguageInfo[] | undefined;
const path = pathOverride ?? this.router.url;
const firstSegment = getFirstPathSegment(path);
const cultureName = findMatchingCultureName(firstSegment, languages);
if (!cultureName) {
return;
}
this.sessionState.setLanguage(cultureName);
}
}

4
npm/ng-packs/packages/core/src/lib/services/title-strategy.service.ts

@ -27,6 +27,10 @@ export class AbpTitleStrategy extends TitleStrategy {
override updateTitle(routerState: RouterStateSnapshot) {
this.routerState = routerState;
if (!routerState?.root) {
return;
}
const title = this.buildTitle(routerState);
const projectName = this.localizationService.instant({

81
npm/ng-packs/packages/core/src/lib/tests/route-based-culture-url.service.spec.ts

@ -0,0 +1,81 @@
import { Location } from '@angular/common';
import { TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { of } from 'rxjs';
import { ConfigStateService } from '../services/config-state.service';
import { RouteBasedCultureUrlService } from '../services/route-based-culture-url.service';
import { SessionStateService } from '../services/session-state.service';
const languages = [
{ cultureName: 'en', displayName: 'English' },
{ cultureName: 'tr', displayName: 'Turkish' },
];
describe('RouteBasedCultureUrlService', () => {
let service: RouteBasedCultureUrlService;
let configState: { getOne: ReturnType<typeof vi.fn> };
let sessionState: {
getLanguage: ReturnType<typeof vi.fn>;
setLanguage: ReturnType<typeof vi.fn>;
};
function setup(useRouteBasedCulture: boolean) {
configState = {
getOne: vi.fn((key: string) => {
if (key === 'localization') {
return { useRouteBasedCulture, languages };
}
return undefined;
}),
getAll$: () => of({}),
};
sessionState = {
getLanguage: vi.fn(() => 'tr'),
setLanguage: vi.fn(),
};
TestBed.configureTestingModule({
providers: [
RouteBasedCultureUrlService,
{ provide: ConfigStateService, useValue: configState },
{ provide: SessionStateService, useValue: sessionState },
{
provide: Router,
useValue: { navigateByUrl: vi.fn(() => Promise.resolve(true)), url: '/tr' },
},
{ provide: Location, useValue: { path: () => '/tr/home' } },
],
});
service = TestBed.inject(RouteBasedCultureUrlService);
}
afterEach(() => {
TestBed.resetTestingModule();
});
test('prefixPathWithCulture leaves path unchanged when useRouteBasedCulture is false', () => {
setup(false);
expect(service.prefixPathWithCulture('/identity/users')).toBe('/identity/users');
});
test('prefixPathWithCulture prepends session culture when enabled', () => {
setup(true);
expect(service.prefixPathWithCulture('/identity/users')).toBe('/tr/identity/users');
});
test('stripCulturePrefixIfEnabled removes leading culture segment', () => {
setup(true);
expect(service.stripCulturePrefixIfEnabled('/en/identity/users')).toBe('/identity/users');
});
test('rewritePathToCulture replaces existing culture segment', () => {
setup(true);
expect(service.rewritePathToCulture('/en/home?x=1', 'tr')).toBe('/tr/home?x=1');
});
test('rewritePathToCulture prepends culture when missing', () => {
setup(true);
expect(service.rewritePathToCulture('/identity/users', 'tr')).toBe('/tr/identity/users');
});
});

81
npm/ng-packs/packages/core/src/lib/tests/route-based-culture.utils.spec.ts

@ -0,0 +1,81 @@
import {
findMatchingCultureName,
getFirstPathSegment,
normalizeUrlForRouteCultureMatch,
stripCultureSegmentFromPath,
} from '../utils/route-based-culture.utils';
import type { LanguageInfo } from '../proxy/volo/abp/localization/models';
describe('route-based-culture.utils', () => {
describe('#getFirstPathSegment', () => {
test.each`
urlPath | expected
${''} | ${''}
${'/'} | ${''}
${'/en'} | ${'en'}
${'/en/account'} | ${'en'}
${'en/account'} | ${'en'}
${'/tr-TR/foo/bar'} | ${'tr-TR'}
${'/zh-Hans/account?x=1'} | ${'zh-Hans'}
${'tr/home#frag'} | ${'tr'}
`('should return $expected when urlPath is $urlPath', ({ urlPath, expected }) => {
expect(getFirstPathSegment(urlPath)).toBe(expected);
});
});
describe('#findMatchingCultureName', () => {
const languages: LanguageInfo[] = [
{ cultureName: 'en' },
{ cultureName: 'tr-TR' },
{ cultureName: 'zh-Hans' },
];
test.each`
segment | expected
${'en'} | ${'en'}
${'EN'} | ${'en'}
${'tr-tr'} | ${'tr-TR'}
${'zh-hans'} | ${'zh-Hans'}
${'account'} | ${undefined}
${''} | ${undefined}
`('should return $expected when segment is $segment', ({ segment, expected }) => {
expect(findMatchingCultureName(segment, languages)).toBe(expected);
});
test('should return undefined when languages is empty', () => {
expect(findMatchingCultureName('en', [])).toBeUndefined();
});
});
describe('#stripCultureSegmentFromPath', () => {
const languages: LanguageInfo[] = [{ cultureName: 'en' }, { cultureName: 'tr' }];
test('should strip leading culture segment for menu matching', () => {
expect(stripCultureSegmentFromPath('/en/identity/users', languages)).toBe('/identity/users');
});
test('should leave path unchanged when first segment is not a culture', () => {
expect(stripCultureSegmentFromPath('/identity/users', languages)).toBe('/identity/users');
});
test('should preserve query string', () => {
expect(stripCultureSegmentFromPath('/en/home?x=1', languages)).toBe('/home?x=1');
});
});
describe('#normalizeUrlForRouteCultureMatch', () => {
const languages: LanguageInfo[] = [{ cultureName: 'en' }];
test('should no-op when useRouteBasedCulture is false', () => {
expect(normalizeUrlForRouteCultureMatch('/en/identity/users', false, languages)).toBe(
'/en/identity/users',
);
});
test('should strip when enabled', () => {
expect(normalizeUrlForRouteCultureMatch('/en/identity/users', true, languages)).toBe(
'/identity/users',
);
});
});
});

44
npm/ng-packs/packages/core/src/lib/tests/route-culture-url-matcher.spec.ts

@ -0,0 +1,44 @@
import { UrlSegment } from '@angular/router';
import {
createRouteCultureUrlMatcher,
isLikelyCultureSegment,
} from '../utils/route-culture-url-matcher';
describe('route-culture-url-matcher', () => {
describe('#isLikelyCultureSegment', () => {
test.each`
segment | expected
${''} | ${false}
${'en'} | ${true}
${'tr'} | ${true}
${'zh-Hans'} | ${true}
${'pt-BR'} | ${true}
${'home'} | ${false}
${'identity'} | ${false}
${'cms-kit'} | ${false}
${'account'} | ${false}
`('should return $expected when segment is $segment', ({ segment, expected }) => {
expect(isLikelyCultureSegment(segment)).toBe(expected);
});
});
describe('#createRouteCultureUrlMatcher', () => {
const matcher = createRouteCultureUrlMatcher();
test('should consume first segment when it is a culture code', () => {
const segments = [new UrlSegment('en', {}), new UrlSegment('home', {})];
const result = matcher(segments, null as any, {} as any);
expect(result?.consumed).toEqual([segments[0]]);
expect(result?.posParams?.culture).toBe(segments[0]);
});
test('should return null when first segment is not a culture code', () => {
const segments = [new UrlSegment('identity', {})];
expect(matcher(segments, null as any, {} as any)).toBeNull();
});
test('should return null when there are no segments', () => {
expect(matcher([], null as any, {} as any)).toBeNull();
});
});
});

2
npm/ng-packs/packages/core/src/lib/utils/index.ts

@ -15,6 +15,8 @@ export * from './multi-tenancy-utils';
export * from './number-utils';
export * from './object-utils';
export * from './queue';
export * from './route-based-culture.utils';
export * from './route-culture-url-matcher';
export * from './route-utils';
export * from './string-utils';
export * from './tree-utils';

6
npm/ng-packs/packages/core/src/lib/utils/initial-utils.ts

@ -1,4 +1,4 @@
import { registerLocaleData } from '@angular/common';
import { Location, registerLocaleData } from '@angular/common';
import { inject, Injector } from '@angular/core';
import { tap, catchError } from 'rxjs/operators';
import { firstValueFrom, lastValueFrom, of, throwError, timeout } from 'rxjs';
@ -7,6 +7,7 @@ import { Environment } from '../models/environment';
import { CurrentTenantDto } from '../proxy/volo/abp/asp-net-core/mvc/multi-tenancy/models';
import { ConfigStateService } from '../services/config-state.service';
import { EnvironmentService } from '../services/environment.service';
import { RouteBasedCultureService } from '../services/route-based-culture.service';
import { SessionStateService } from '../services/session-state.service';
import { CORE_OPTIONS } from '../tokens/options.token';
import { APP_INIT_ERROR_HANDLERS } from '../tokens/app-config.token';
@ -63,6 +64,9 @@ export async function getInitialData() {
await lastValueFrom(result$);
}
const routeBasedCulture = injector.get(RouteBasedCultureService);
routeBasedCulture.syncLanguageFromUrl(injector.get(Location).path());
await localeInitializer(injector);
}

82
npm/ng-packs/packages/core/src/lib/utils/route-based-culture.utils.ts

@ -0,0 +1,82 @@
import type { LanguageInfo } from '../proxy/volo/abp/localization/models';
/**
* Returns the first path segment of a URL path (leading slashes are ignored).
* Strips query string and fragment first (e.g. "/zh-Hans/account?x=1" "zh-Hans").
*/
export function getFirstPathSegment(urlPath: string): string {
if (!urlPath) {
return '';
}
const suffixIndex = urlPath.search(/[?#]/);
const pathPart = suffixIndex >= 0 ? urlPath.slice(0, suffixIndex) : urlPath;
const segments = pathPart.split('/').filter(s => s.length > 0);
return segments[0] ?? '';
}
/**
* If the first segment matches a configured language culture name (case-insensitive),
* returns the canonical culture name from configuration; otherwise undefined.
*/
export function findMatchingCultureName(
firstSegment: string,
languages: LanguageInfo[] | undefined,
): string | undefined {
if (!firstSegment || !languages?.length) {
return undefined;
}
const normalized = firstSegment.toLowerCase();
const match = languages.find(
lang => lang.cultureName && lang.cultureName.toLowerCase() === normalized,
);
return match?.cultureName;
}
/**
* Removes the first path segment when it matches a configured UI culture (e.g. `/en/identity/users` `/identity/users`).
* Use when comparing the **browser URL** to **menu links** that omit the culture segment.
*/
export function stripCultureSegmentFromPath(
path: string,
languages: LanguageInfo[] | undefined,
): string {
if (!path || !languages?.length) {
return path;
}
const pathEnd = path.search(/[?#]/);
const pathOnly = pathEnd >= 0 ? path.slice(0, pathEnd) : path;
const suffix = pathEnd >= 0 ? path.slice(pathEnd) : '';
const first = getFirstPathSegment(pathOnly);
if (!first || !findMatchingCultureName(first, languages)) {
return path;
}
const segments = pathOnly.split('/').filter(s => s.length > 0);
if (segments.length === 0) {
return path;
}
const rest = segments.slice(1);
const normalized = rest.length ? '/' + rest.join('/') : '/';
return normalized + suffix;
}
/**
* When route-based culture is enabled, normalizes URLs for comparison with menu routes that do not include a culture prefix.
*/
export function normalizeUrlForRouteCultureMatch(
path: string,
useRouteBasedCulture: boolean,
languages: LanguageInfo[] | undefined,
): string {
if (!useRouteBasedCulture || !path) {
return path;
}
return stripCultureSegmentFromPath(path, languages);
}

59
npm/ng-packs/packages/core/src/lib/utils/route-culture-url-matcher.ts

@ -0,0 +1,59 @@
import { Route, Routes, UrlMatchResult, UrlSegment, UrlSegmentGroup } from '@angular/router';
/**
* Heuristic: first path segment looks like a BCP 47-style culture code (e.g. en, tr, zh-Hans, pt-BR).
* Used by the optional culture URL matcher so real routes like `identity` or `cms-kit` are not mistaken for cultures.
*/
export function isLikelyCultureSegment(segment: string): boolean {
if (!segment) {
return false;
}
return /^[a-z]{2}(-[a-zA-Z0-9]{2,8})?$/.test(segment);
}
/**
* Matcher that consumes the first segment when it looks like a culture code.
* Exposes it as the route param `culture` on matched routes.
*/
export function createRouteCultureUrlMatcher(): (
segments: UrlSegment[],
group: UrlSegmentGroup,
route: Route,
) => UrlMatchResult | null {
return (segments: UrlSegment[]) => {
if (segments.length < 1) {
return null;
}
const first = segments[0].path;
if (!isLikelyCultureSegment(first)) {
return null;
}
return {
consumed: [segments[0]],
posParams: { culture: segments[0] },
};
};
}
/**
* Wraps your app routes so the same URLs work with or without a leading culture segment.
*
* Examples (same components for both shapes):
* - `/` and `/en`
* - `/home` and `/en/home`
* - `/identity/users` and `/en/identity/users`
*
* The culture segment is matched only when it passes {@link isLikelyCultureSegment} (e.g. `en`, `tr-TR`, `zh-Hans`).
* Session language from the URL is still applied only when `localization.useRouteBasedCulture` is true (`RouteBasedCultureService`).
*/
export function withOptionalRouteCulturePrefix(routes: Routes): Routes {
const matcher = createRouteCultureUrlMatcher();
return [
{ matcher, children: [...routes] },
{ path: '', children: [...routes] },
];
}

4
npm/ng-packs/packages/theme-basic/src/lib/components/routes/routes.component.html

@ -6,7 +6,7 @@
<ng-template #defaultLink let-route>
<li class="nav-item" *abpPermission="route.requiredPolicy">
<a class="nav-link" [routerLink]="[route.path]">
<a class="nav-link" [routerLink]="route.path | abpRouteCultureUrl">
@if (route.iconClass) {
<i [class]="route.iconClass"></i>
}
@ -47,7 +47,7 @@
<ng-template #defaultChild let-child>
@if (child.path) {
<div class="dropdown-submenu" *abpPermission="child.requiredPolicy">
<a class="dropdown-item" [routerLink]="[child.path]" (click)="closeDropdown()">
<a class="dropdown-item" [routerLink]="child.path | abpRouteCultureUrl" (click)="closeDropdown()">
@if (child.iconClass) {
<i [class]="child.iconClass"></i>
}

2
npm/ng-packs/packages/theme-basic/src/lib/components/routes/routes.component.ts

@ -1,5 +1,6 @@
import {
ABP,
AbpRouteCultureUrlPipe,
AsyncLocalizationPipe,
LocalizationPipe,
PermissionDirective,
@ -32,6 +33,7 @@ import { EllipsisDirective } from '@abp/ng.theme.shared';
PermissionDirective,
EllipsisDirective,
LocalizationPipe,
AbpRouteCultureUrlPipe,
],
})
export class RoutesComponent {

2
npm/ng-packs/packages/theme-shared/src/lib/components/breadcrumb-items/breadcrumb-items.component.html

@ -14,7 +14,7 @@
}
<ng-template #linkTemplate let-item>
<a [routerLink]="item.path"> {{ item.name | abpLocalization }}</a>
<a [routerLink]="item.path | abpRouteCultureUrl"> {{ item.name | abpLocalization }}</a>
</ng-template>
<ng-template #textTemplate let-item>

4
npm/ng-packs/packages/theme-shared/src/lib/components/breadcrumb-items/breadcrumb-items.component.ts

@ -1,12 +1,12 @@
import { Component, input } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';
import { RouterLink } from '@angular/router';
import { ABP, LocalizationPipe } from '@abp/ng.core';
import { ABP, AbpRouteCultureUrlPipe, LocalizationPipe } from '@abp/ng.core';
@Component({
selector: 'abp-breadcrumb-items',
templateUrl: './breadcrumb-items.component.html',
imports: [NgTemplateOutlet, RouterLink, LocalizationPipe],
imports: [NgTemplateOutlet, RouterLink, LocalizationPipe, AbpRouteCultureUrlPipe],
})
export class BreadcrumbItemsComponent {
readonly items = input<Partial<ABP.Route>[]>([]);

7
npm/ng-packs/packages/theme-shared/src/lib/components/breadcrumb/breadcrumb.component.ts

@ -1,6 +1,6 @@
import {
ABP,
getRoutePath,
RouteBasedCultureUrlService,
RouterEvents,
RoutesService,
SubscriptionService,
@ -25,6 +25,7 @@ export class BreadcrumbComponent implements OnInit {
private routes = inject(RoutesService);
private subscription = inject(SubscriptionService);
private routerEvents = inject(RouterEvents);
private routeCultureUrl = inject(RouteBasedCultureUrlService);
segments: Partial<ABP.Route>[] = [];
@ -32,7 +33,9 @@ export class BreadcrumbComponent implements OnInit {
this.subscription.addOne(
this.routerEvents.getNavigationEvents('End').pipe(
startWith(null),
map(() => this.routes.search({ path: getRoutePath(this.router) })),
map(() =>
this.routes.search({ path: this.routeCultureUrl.getRoutePathForMatching(this.router) }),
),
),
route => {
this.segments = [];

Loading…
Cancel
Save