@ -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. |
||||
|
|
||||
|
 |
||||
|
|
||||
|
 |
||||
|
|
||||
|
## 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. |
||||
|
|
||||
|
 |
||||
|
|
||||
|
 |
||||
|
|
||||
|
ABP's built-in module pages (Identity, Settings, etc.) also work with URL-based localization out of the box: |
||||
|
|
||||
|
 |
||||
|
|
||||
|
### 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) |
||||
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 93 KiB |
@ -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); |
||||
|
```` |
||||
|
|
||||
|
 |
||||
|
|
||||
|
### 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. |
||||
|
|
||||
|
 |
||||
|
|
||||
|
### 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. |
||||
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 81 KiB |
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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 + "/"; |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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, |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -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)); |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,8 @@ |
|||||
|
using System.Threading.Tasks; |
||||
|
|
||||
|
namespace Volo.Abp.UI.Navigation; |
||||
|
|
||||
|
public interface IMenuItemCulturePrefixHelper |
||||
|
{ |
||||
|
Task PrependCulturePrefixAsync(IHasMenuItems menuWithItems, string prefix); |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,11 @@ |
|||||
|
namespace Volo.Abp.UI.Navigation; |
||||
|
|
||||
|
public class MenuItemUrlProviderContext |
||||
|
{ |
||||
|
public ApplicationMenu Menu { get; } |
||||
|
|
||||
|
public MenuItemUrlProviderContext(ApplicationMenu menu) |
||||
|
{ |
||||
|
Menu = menu; |
||||
|
} |
||||
|
} |
||||
@ -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 })); |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
|
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -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(); |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
}); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
@ -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"); |
||||
|
} |
||||
|
} |
||||
@ -1,25 +1,22 @@ |
|||||
@inject NavigationManager Navigation |
|
||||
@using Volo.Abp.DependencyInjection |
@using Volo.Abp.DependencyInjection |
||||
@using Volo.Abp.AspNetCore.Components.Web.BasicTheme.Themes.Basic |
@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 Microsoft.Extensions.Options |
||||
|
@using Volo.Abp.AspNetCore.Components.WebAssembly |
||||
@using Volo.Abp.AspNetCore.Components.Web |
@using Volo.Abp.AspNetCore.Components.Web |
||||
@inherits RedirectToLogin |
@inherits RedirectToLogin |
||||
@attribute [ExposeServices(typeof(RedirectToLogin))] |
@attribute [ExposeServices(typeof(RedirectToLogin))] |
||||
@attribute [Dependency(ReplaceServices = true)] |
@attribute [Dependency(ReplaceServices = true)] |
||||
@inject IOptions<AuthenticationOptions> AuthenticationOptions |
@inject NavigationManager Navigation |
||||
|
@inject IOptions<AuthenticationOptions> AuthOptions |
||||
|
@inject IRouteBasedCultureUrlHelper CultureUrlHelper |
||||
@inject IOptions<AbpAspNetCoreComponentsWebOptions> AbpAspNetCoreComponentsWebOptions |
@inject IOptions<AbpAspNetCoreComponentsWebOptions> AbpAspNetCoreComponentsWebOptions |
||||
|
|
||||
@code { |
@code { |
||||
protected override void OnInitialized() |
protected override void OnInitialized() { } |
||||
|
|
||||
|
protected override Task OnInitializedAsync() |
||||
{ |
{ |
||||
if (AbpAspNetCoreComponentsWebOptions.Value.IsBlazorWebApp) |
return CultureAwareRedirectToLoginHelper.RedirectAsync(Navigation, AuthOptions.Value.LoginUrl, CultureUrlHelper, AbpAspNetCoreComponentsWebOptions); |
||||
{ |
|
||||
Navigation.NavigateTo(AuthenticationOptions.Value.LoginUrl, forceLoad: true); |
|
||||
} |
|
||||
else |
|
||||
{ |
|
||||
Navigation.NavigateToLogin(AuthenticationOptions.Value.LoginUrl); |
|
||||
} |
|
||||
} |
} |
||||
} |
} |
||||
|
|||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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}`; |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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'); |
||||
|
}); |
||||
|
}); |
||||
@ -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', |
||||
|
); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
@ -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(); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
@ -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); |
||||
|
} |
||||
@ -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] }, |
||||
|
]; |
||||
|
} |
||||