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