diff --git a/docs/en/Community-Articles/2026-03-29-Url-Based-Localization/POST.md b/docs/en/Community-Articles/2026-03-29-Url-Based-Localization/POST.md new file mode 100644 index 00000000000..e8a5d160159 --- /dev/null +++ b/docs/en/Community-Articles/2026-03-29-Url-Based-Localization/POST.md @@ -0,0 +1,167 @@ +# SEO-Friendly Localized URLs in ABP with a Single Line of Configuration + +ABP has always supported language switching via the `?culture=en` query string and the culture cookie. That works fine for most applications — but it has a limitation that shows up quickly once SEO or link-sharing matters. + +Consider a book-store app where users browse in their language: + +- A Spanish user shares a product link. The recipient opens it in English because the cookie on *their* machine says `en`. +- Search engines crawl the same URL in every language, making it impossible to create separate sitemaps per locale. +- A user shares a link like `/Books/Detail?id=42&culture=es`. When the server processes the request, it sets the culture cookie and then redirects to `/Books/Detail?id=42` — stripping the `?culture=` parameter. The shared link no longer carries the intended language. + +Embedding the culture in the URL path — `/es/books`, `/zh-Hans/about` — solves all three. Each language has its own stable URL, readable by humans and index-friendly for search engines. + +ABP supports this out of the box. You opt in with a single configuration property, and the framework takes care of routing, URL generation, menu links, and language switching automatically. + +## Enabling URL-Based Localization + +In your ABP module class, add: + +```csharp +Configure(options => +{ + options.UseRouteBasedCulture = true; +}); +``` + +That is the only change you need to make. + +## MVC / Razor Pages + +MVC and Razor Pages have the most complete support — everything works automatically. No code changes needed in your pages or controllers. + +![MVC sample — English](images/mvc-home-en.png) + +![MVC sample — Turkish](images/mvc-home-tr.png) + +## What Happens Automatically + +When you set `UseRouteBasedCulture = true`, ABP automatically: + +- Registers ASP.NET Core's built-in [`RouteDataRequestCultureProvider`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.localization.routing.routedatarequestcultureprovider) to detect culture from the URL path. +- Adds a `{culture}/{controller}/{action}` conventional route for MVC controllers, with a route constraint to prevent non-culture URL segments (like `/enterprise/products`) from matching. +- Adds `{culture}/...` route selectors to all Razor Pages at startup. +- Injects the current culture into all `Url.Page()` and `Url.Action()` calls, so generated URLs automatically include the culture prefix. +- Prepends the culture prefix to navigation menu item URLs. + +You do not need to configure these individually. + +## URL Generation Just Works + +In a Razor Page or view running under a culture-prefixed URL (say, `/zh-Hans/Books`), you do not need to pass a `culture` parameter anywhere: + +```cshtml +@Url.Page("/Books/Detail", new { id = book.Id }) +@* Generates: /zh-Hans/Books/Detail?id=42 *@ + +@Url.Action("About", "Home") +@* Generates: /zh-Hans/Home/About *@ +``` + +If you explicitly pass a different `culture` value, that takes precedence — so cross-language links are also straightforward: + +```cshtml +@Url.Page("/Books/Index", new { culture = "tr" }) +@* Generates: /tr/Books *@ +``` + +## Language Switching + +The built-in ABP language switcher already works with route-based culture. When a user switches language, the culture segment in the URL is automatically replaced: + +| Current URL | Switch to | Redirect to | +|---|---|---| +| `/tr/books` | `en` | `/en/books` | +| `/zh-Hans/about` | `en` | `/en/about` | +| `/tenant-a/zh-Hans/about` | `en` | `/tenant-a/en/about` | + +No theme changes, no language switcher changes — the existing UI component just works. + +## Blazor Support + +Blazor Server and Blazor WebAssembly (WebApp) both support URL-based localization. Culture detection and cookie persistence work automatically on the initial page load (SSR). Menu URLs and language switching also work automatically. + +![Blazor Server sample](images/blazor-server-zh-hans.png) + +![Blazor WebApp sample](images/blazor-webapp-tr.png) + +ABP's built-in module pages (Identity, Settings, etc.) also work with URL-based localization out of the box: + +![Identity module — User Management](images/module-identity-users.png) + +### Manual step: Blazor component routes + +The only manual step for Blazor is adding `@page "/{culture}/..."` routes to your own pages. ASP.NET Core does not support automatically adding route selectors to Blazor components (unlike Razor Pages), so you must add them explicitly: + +```razor +@page "/" +@page "/{culture}" + +@code { + [Parameter] + public string? Culture { get; set; } +} +``` + +```razor +@page "/Products" +@page "/{culture}/Products" + +@code { + [Parameter] + public string? Culture { get; set; } +} +``` + +> **ABP's built-in module pages** (Identity, Tenant Management, Settings, Account, etc.) already ship with `@page "/{culture}/..."` route variants. You only need to add these routes to your own application pages. + +### Blazor WebApp (WASM) configuration + +The WASM client project does not need any `UseRouteBasedCulture` configuration. It reads the setting from the server automatically. + +```csharp +// Server project — the only place you need to configure +Configure(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) diff --git a/docs/en/Community-Articles/2026-03-29-Url-Based-Localization/cover.png b/docs/en/Community-Articles/2026-03-29-Url-Based-Localization/cover.png new file mode 100644 index 00000000000..fcea11e4d61 Binary files /dev/null and b/docs/en/Community-Articles/2026-03-29-Url-Based-Localization/cover.png differ diff --git a/docs/en/Community-Articles/2026-03-29-Url-Based-Localization/images/blazor-server-zh-hans.png b/docs/en/Community-Articles/2026-03-29-Url-Based-Localization/images/blazor-server-zh-hans.png new file mode 100644 index 00000000000..7db95b4b163 Binary files /dev/null and b/docs/en/Community-Articles/2026-03-29-Url-Based-Localization/images/blazor-server-zh-hans.png differ diff --git a/docs/en/Community-Articles/2026-03-29-Url-Based-Localization/images/blazor-webapp-tr.png b/docs/en/Community-Articles/2026-03-29-Url-Based-Localization/images/blazor-webapp-tr.png new file mode 100644 index 00000000000..91008fd572b Binary files /dev/null and b/docs/en/Community-Articles/2026-03-29-Url-Based-Localization/images/blazor-webapp-tr.png differ diff --git a/docs/en/Community-Articles/2026-03-29-Url-Based-Localization/images/module-identity-users.png b/docs/en/Community-Articles/2026-03-29-Url-Based-Localization/images/module-identity-users.png new file mode 100644 index 00000000000..bd4b996df44 Binary files /dev/null and b/docs/en/Community-Articles/2026-03-29-Url-Based-Localization/images/module-identity-users.png differ diff --git a/docs/en/Community-Articles/2026-03-29-Url-Based-Localization/images/mvc-home-en.png b/docs/en/Community-Articles/2026-03-29-Url-Based-Localization/images/mvc-home-en.png new file mode 100644 index 00000000000..e9bc6a9cbd1 Binary files /dev/null and b/docs/en/Community-Articles/2026-03-29-Url-Based-Localization/images/mvc-home-en.png differ diff --git a/docs/en/Community-Articles/2026-03-29-Url-Based-Localization/images/mvc-home-tr.png b/docs/en/Community-Articles/2026-03-29-Url-Based-Localization/images/mvc-home-tr.png new file mode 100644 index 00000000000..01bee42b315 Binary files /dev/null and b/docs/en/Community-Articles/2026-03-29-Url-Based-Localization/images/mvc-home-tr.png differ diff --git a/docs/en/framework/fundamentals/index.md b/docs/en/framework/fundamentals/index.md index 7ffa99fd89f..5becda669b9 100644 --- a/docs/en/framework/fundamentals/index.md +++ b/docs/en/framework/fundamentals/index.md @@ -17,6 +17,7 @@ The following documents explains the fundamental building blocks to create ABP s * [Dependency Injection](./dependency-injection.md) * [Exception Handling](./exception-handling.md) * [Localization](./localization.md) +* [URL-Based Localization](./url-based-localization.md) * [Logging](./logging.md) * [Object Extensions](./object-extensions.md) * [Options](./options.md) diff --git a/docs/en/framework/fundamentals/localization.md b/docs/en/framework/fundamentals/localization.md index 7a4fcf24ac5..562446a8920 100644 --- a/docs/en/framework/fundamentals/localization.md +++ b/docs/en/framework/fundamentals/localization.md @@ -294,6 +294,10 @@ Configure(options => }); ``` +## URL-Based Localization + +ABP supports embedding the culture code directly in the URL path (e.g. `/en/products`, `/zh-Hans/about`), which is useful for SEO-friendly and shareable localized URLs. See the [URL-Based Localization](./url-based-localization.md) document for details. + ## The Client Side See the following documents to learn how to reuse the same localization texts in the JavaScript side; diff --git a/docs/en/framework/fundamentals/url-based-localization.md b/docs/en/framework/fundamentals/url-based-localization.md new file mode 100644 index 00000000000..55d29b50a18 --- /dev/null +++ b/docs/en/framework/fundamentals/url-based-localization.md @@ -0,0 +1,173 @@ +````json +//[doc-seo] +{ + "Description": "Learn how to use ABP's URL-based localization to embed culture in the URL path, enabling SEO-friendly and shareable localized URLs." +} +```` + +# URL-Based Localization + +ABP supports embedding the current culture directly in the URL path, for example `/tr/products` or `/en/about`. This approach is widely used by documentation sites, e-commerce platforms, and any site that needs SEO-friendly, shareable localized URLs. + +By default, ABP detects language from QueryString (`?culture=tr`), Cookie, and `Accept-Language` header. URL path detection is **opt-in** and fully backward-compatible. + +## Enabling URL-Based Localization + +Configure the `AbpRequestLocalizationOptions` in your [module class](../architecture/modularity/basics.md): + +````csharp +Configure(options => +{ + options.UseRouteBasedCulture = true; +}); +```` + +That's all you need. The framework automatically handles the rest. + +## What Happens Automatically + +When you set `UseRouteBasedCulture` to `true`, ABP automatically registers the following: + +* **`RouteDataRequestCultureProvider`** — A built-in ASP.NET Core provider that reads `{culture}` from route data. ABP inserts it after `QueryStringRequestCultureProvider` and before `CookieRequestCultureProvider`. +* **`{culture}/{controller}/{action}` route** — A conventional route for MVC controllers. The `{culture}` parameter uses a custom route constraint (`AbpCultureRouteConstraint`) that only matches culture values configured in `AbpLocalizationOptions.Languages`, so URLs like `/enterprise/products` are not mistaken for culture-prefixed routes. +* **`AbpCultureRoutePagesConvention`** — An `IPageRouteModelConvention` that adds `{culture}/...` route selectors to all Razor Pages. +* **`AbpCultureRouteUrlHelperFactory`** — Replaces the default `IUrlHelperFactory` to auto-inject culture into `Url.Page()` and `Url.Action()` calls. +* **`AbpCultureMenuItemUrlProvider`** — Prepends the culture prefix to navigation menu item URLs (MVC / Blazor Server). +* **`AbpWasmCultureMenuItemUrlProvider`** — Prepends the culture prefix to menu item URLs in Blazor WebAssembly (reads the `UseRouteBasedCulture` flag from `/api/abp/application-configuration`). + +You do not need to configure these individually. + +## URL Generation + +When a request has a `{culture}` route value, all URL generation methods automatically include the culture prefix: + +````csharp +// In a Razor Page — culture is auto-injected, no manual parameter needed +@Url.Page("/About") // Generates: /zh-Hans/About +@Url.Action("About", "Home") // Generates: /zh-Hans/Home/About +```` + +Menu items registered via `IMenuContributor` also automatically get the culture prefix. No changes are needed in your menu contributors or theme. + +## Language Switching + +ABP's built-in language switcher (the `/Abp/Languages/Switch` action) automatically replaces the culture segment in the `returnUrl`. The controller reads the culture from the request cookie to identify the current page culture and replaces it with the new one: + +| Before switching | After switching to English | +|---|---| +| `/tr/products` | `/en/products` | +| `/tenant-a/zh-Hans/about` | `/tenant-a/en/about` | +| `/home?culture=tr&ui-culture=tr` | `/home?culture=en&ui-culture=en` | +| `/about` (no prefix) | `/about` (unchanged) | + +No changes are needed in any theme or language switcher component. + +## MVC / Razor Pages + +MVC and Razor Pages have the most complete support. Everything works automatically when `UseRouteBasedCulture = true` — route registration, URL generation, menu links, and language switching. **No code changes are needed in your pages or controllers.** + +## Blazor Server + +Blazor Server uses SignalR (WebSocket) for the interactive circuit. The HTTP middleware pipeline only runs on the **initial page load** — subsequent interactions happen over the WebSocket connection. ABP handles this by persisting the detected URL culture to a **Cookie** on the first request, so the entire Blazor circuit uses the correct language. + +Culture detection, cookie persistence, menu URLs, and language switching all work automatically. No additional configuration is needed beyond the `UseRouteBasedCulture` option. + +### What requires manual changes + +**Blazor component routes**: ASP.NET Core does not provide an `IPageRouteModelConvention` equivalent for Blazor components. You must manually add the `{culture}` route to each page: + +````razor +@page "/" +@page "/{culture}" + +@code { + [Parameter] + public string? Culture { get; set; } +} +```` + +````razor +@page "/About" +@page "/{culture}/About" + +@code { + [Parameter] + public string? Culture { get; set; } +} +```` + +> This applies to your own application pages. ABP built-in module pages (Identity, Tenant Management, Settings, Account, etc.) already include `@page "/{culture}/..."` routes out of the box — you do not need to add them manually. + +## Blazor WebAssembly (WebApp) + +Blazor WebAssembly (WASM) runs in the browser. On the **first page load**, the server renders the page via SSR, and the culture is detected from the URL. After WASM downloads, subsequent renders run in the browser. The WASM app fetches `/api/abp/application-configuration` from the server to get the current culture, so the culture stays consistent. + +Culture detection, cookie persistence, menu URLs, and language switching all work automatically. The WASM client reads the `UseRouteBasedCulture` flag from the server via `/api/abp/application-configuration`, so no client-side configuration is needed. + +### What requires manual changes + +Same as Blazor Server — you must manually add `@page "/{culture}/..."` routes to your Blazor pages. + +## Angular + +The [ABP Angular UI](../ui/angular/quick-start.md) runs in the browser. The server still applies `UseRouteBasedCulture`; the client reads **`localization.useRouteBasedCulture`** from `/api/abp/application-configuration` (same payload as other UI types). There is no separate Angular setting. + +### Routing + +Angular does not add a culture segment to your route config automatically. Use **`withOptionalRouteCulturePrefix`** from **`@abp/ng.core`** so one route tree matches both **`/identity/users`** and **`/en/identity/users`** (the first path segment is matched only when it looks like a culture code, e.g. `en`, `tr`, `zh-Hans`). + +````typescript +import { Routes } from '@angular/router'; +import { withOptionalRouteCulturePrefix } from '@abp/ng.core'; + +const appRoutesCore: Routes = [ + // ... your routes (path: '', 'account', 'identity', lazy children, etc.) +]; + +export const appRoutes = withOptionalRouteCulturePrefix(appRoutesCore); +```` + +![Angular: routes wrapped with optional culture prefix](../../images/url-based-localization-angular-routes.png) + +### URL → session language + +When **`useRouteBasedCulture`** is **true**, **`RouteBasedCultureService`** (from `@abp/ng.core`) keeps the session language aligned with the first URL segment after navigation. This runs during application bootstrap and on each **`NavigationEnd`**. + +### Menu links, breadcrumbs, and `routerLink` + +Menu paths from **`RoutesService`** are usually **without** a culture prefix (`/identity/users`). Use the **`abpRouteCultureUrl`** pipe on **`routerLink`** (or **`RouteBasedCultureUrlService.prefixPathWithCulture`**) so links navigate to **`/en/identity/users`** when route-based culture is enabled. The **Basic** theme navigation and **Theme Shared** breadcrumb links follow this pattern. + +![Angular: culture-prefixed menu or URL bar](../../images/url-based-localization-angular-menu-url.png) + +### Language switcher (toolbar) + +If the user selects a language in the UI, call **`RouteBasedCultureUrlService.applyLanguageSelection(cultureName)`** (or **`navigateToUrlWithCulture`**) instead of only updating the session language. That rewrites the current URL’s culture segment (or prepends it) so the address bar and session stay consistent; **`RouteBasedCultureService`** then picks up the culture from the URL after navigation. + +### Active menu, breadcrumbs, and route matching + +The browser URL may be **`/en/identity/users`** while menu items and **`RoutesService`** paths stay **`/identity/users`**. For comparisons (active state, **`findRoute`**, permission guard, dynamic layout), normalize the current URL with **`RouteBasedCultureUrlService.normalizeForMenuMatch`** (or **`stripCulturePrefixIfEnabled`**) or use **`getRoutePathForMatching`** where **`getRoutePath`** was used. + +### Configuration refresh + +**`RouteBasedCultureUrlService`** refreshes its cached **`useRouteBasedCulture`** and **languages** when application configuration is updated (for example after **`refreshAppState`**), so hot paths do not query configuration on every change detection cycle. + +## Multi-Tenancy Compatibility + +URL-based localization is fully compatible with [multi-tenancy URL routing](../architecture/multi-tenancy/index.md). The culture route is registered as a conventional route `{culture}/{controller}/{action}`. If your application uses tenant routing (e.g., `/{tenant}/...`), the tenant middleware strips the tenant segment before routing, and the culture segment is handled separately. + +Language switching also supports tenant-prefixed URLs. For example, `/tenant-a/zh-Hans/About` correctly switches to `/tenant-a/en/About`. + +## API Routes + +Routes like `/api/products` have no `{culture}` segment, so `RouteDataRequestCultureProvider` returns `null` and falls through to the next provider (Cookie → `Accept-Language` → default). API routes are completely unaffected. + +## Culture Detection Priority + +ASP.NET Core has a built-in [`RouteDataRequestCultureProvider`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.localization.routing.routedatarequestcultureprovider) (in `Microsoft.AspNetCore.Localization.Routing`) that reads culture from route data, but it is not included in the default provider list. When `UseRouteBasedCulture` is enabled, ABP inserts it after `QueryStringRequestCultureProvider` and before `CookieRequestCultureProvider`. The resulting provider order is: + +1. `QueryStringRequestCultureProvider` (ASP.NET Core default — useful for debugging and testing) +2. `RouteDataRequestCultureProvider` (URL path — inserted by ABP when enabled) +3. `CookieRequestCultureProvider` (ASP.NET Core default) +4. `AcceptLanguageHeaderRequestCultureProvider` (ASP.NET Core default) + +If a URL contains an invalid culture code (e.g. `/xyz1234/page`), `RequestLocalizationMiddleware` ignores it and falls through to the next provider. No error is thrown. diff --git a/docs/en/images/url-based-localization-angular-menu-url.png b/docs/en/images/url-based-localization-angular-menu-url.png new file mode 100644 index 00000000000..f64ab92486a Binary files /dev/null and b/docs/en/images/url-based-localization-angular-menu-url.png differ diff --git a/docs/en/images/url-based-localization-angular-routes.png b/docs/en/images/url-based-localization-angular-routes.png new file mode 100644 index 00000000000..9ea19d386a8 Binary files /dev/null and b/docs/en/images/url-based-localization-angular-routes.png differ diff --git a/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/AbpWasmCultureMenuItemUrlProvider.cs b/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/AbpWasmCultureMenuItemUrlProvider.cs new file mode 100644 index 00000000000..9a1077e1b09 --- /dev/null +++ b/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/AbpWasmCultureMenuItemUrlProvider.cs @@ -0,0 +1,63 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using Volo.Abp.AspNetCore.Mvc.Client; +using Volo.Abp.DependencyInjection; +using Volo.Abp.UI.Navigation; + +namespace Volo.Abp.AspNetCore.Components.WebAssembly.Theming; + +/// +/// Prepends the culture route prefix to menu item URLs in Blazor WebAssembly when route-based culture is enabled. +/// +public class AbpWasmCultureMenuItemUrlProvider : IMenuItemUrlProvider, ITransientDependency +{ + protected ICachedApplicationConfigurationClient ConfigurationClient { get; } + 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; + } +} diff --git a/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/CultureAwareAuthenticationBase.cs b/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/CultureAwareAuthenticationBase.cs new file mode 100644 index 00000000000..26a25b7f0ec --- /dev/null +++ b/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/CultureAwareAuthenticationBase.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Components; +using Volo.Abp.AspNetCore.Components; + +namespace Volo.Abp.AspNetCore.Components.WebAssembly.Theming; + +/// +/// Shared base for WASM theme Authentication pages. +/// Provides a helper so the culture-aware +/// home URL construction is not duplicated across theme packages. +/// +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 + "/"; + } +} diff --git a/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/CultureAwareRedirectToLoginHelper.cs b/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/CultureAwareRedirectToLoginHelper.cs new file mode 100644 index 00000000000..02c2b749bf9 --- /dev/null +++ b/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/CultureAwareRedirectToLoginHelper.cs @@ -0,0 +1,35 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.WebAssembly.Authentication; +using Microsoft.Extensions.Options; +using Volo.Abp.AspNetCore.Components.Web; +using Volo.Abp.AspNetCore.Components.WebAssembly; + +namespace Volo.Abp.AspNetCore.Components.WebAssembly.Theming; + +/// +/// Provides the shared culture-aware login redirect logic for all WASM theme +/// WebAssemblyRedirectToLogin components. Each theme must keep +/// @inherits RedirectToLogin for ABP service-replacement assignability, +/// so a common component base class is not feasible; this static helper +/// centralises the logic instead. +/// +public static class CultureAwareRedirectToLoginHelper +{ + public static async Task RedirectAsync( + NavigationManager navigation, + string loginUrl, + IRouteBasedCultureUrlHelper cultureUrlHelper, + IOptions webOptions) + { + var cultureLoginUrl = await cultureUrlHelper.PrependCulturePrefixAsync(loginUrl); + if (webOptions.Value.IsBlazorWebApp) + { + navigation.NavigateTo(cultureLoginUrl, forceLoad: true); + } + else + { + navigation.NavigateToLogin(cultureLoginUrl); + } + } +} diff --git a/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/Volo.Abp.AspNetCore.Components.WebAssembly.Theming.csproj b/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/Volo.Abp.AspNetCore.Components.WebAssembly.Theming.csproj index b3fc2633a7d..76b63c76c75 100644 --- a/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/Volo.Abp.AspNetCore.Components.WebAssembly.Theming.csproj +++ b/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/Volo.Abp.AspNetCore.Components.WebAssembly.Theming.csproj @@ -9,6 +9,10 @@ Nullable + + + + diff --git a/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly/Volo/Abp/AspNetCore/Components/WebAssembly/IRouteBasedCultureNavigationHelper.cs b/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly/Volo/Abp/AspNetCore/Components/WebAssembly/IRouteBasedCultureNavigationHelper.cs new file mode 100644 index 00000000000..b2b34e1a650 --- /dev/null +++ b/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly/Volo/Abp/AspNetCore/Components/WebAssembly/IRouteBasedCultureNavigationHelper.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components; +using Volo.Abp.Localization; + +namespace Volo.Abp.AspNetCore.Components.WebAssembly; + +public interface IRouteBasedCultureNavigationHelper +{ + Task NavigateToNewCultureAsync( + NavigationManager navigationManager, + LanguageInfo newLanguage, + IEnumerable allLanguages); +} diff --git a/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly/Volo/Abp/AspNetCore/Components/WebAssembly/IRouteBasedCultureUrlHelper.cs b/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly/Volo/Abp/AspNetCore/Components/WebAssembly/IRouteBasedCultureUrlHelper.cs new file mode 100644 index 00000000000..f56aeaf6933 --- /dev/null +++ b/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly/Volo/Abp/AspNetCore/Components/WebAssembly/IRouteBasedCultureUrlHelper.cs @@ -0,0 +1,14 @@ +using System.Threading.Tasks; + +namespace Volo.Abp.AspNetCore.Components.WebAssembly; + +public interface IRouteBasedCultureUrlHelper +{ + /// + /// Prepends the current culture to when route-based culture is enabled + /// and the current culture is a known application language. + /// Returns the original unchanged when the feature is disabled or the + /// culture is not recognised. + /// + Task PrependCulturePrefixAsync(string url); +} diff --git a/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly/Volo/Abp/AspNetCore/Components/WebAssembly/RouteBasedCultureNavigationHelper.cs b/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly/Volo/Abp/AspNetCore/Components/WebAssembly/RouteBasedCultureNavigationHelper.cs new file mode 100644 index 00000000000..6a16f7430a1 --- /dev/null +++ b/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly/Volo/Abp/AspNetCore/Components/WebAssembly/RouteBasedCultureNavigationHelper.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Localization; + +namespace Volo.Abp.AspNetCore.Components.WebAssembly; + +public class RouteBasedCultureNavigationHelper : IRouteBasedCultureNavigationHelper, ITransientDependency +{ + public virtual Task NavigateToNewCultureAsync( + NavigationManager navigationManager, + LanguageInfo newLanguage, + IEnumerable 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; + } + + /// + /// Returns the first path segment of , + /// stripping any query string or fragment before splitting on '/'. + /// For example: "zh-Hans/account?x=1" → "zh-Hans", "tr/home#top" → "tr". + /// + 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; + } +} diff --git a/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly/Volo/Abp/AspNetCore/Components/WebAssembly/RouteBasedCultureUrlHelper.cs b/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly/Volo/Abp/AspNetCore/Components/WebAssembly/RouteBasedCultureUrlHelper.cs new file mode 100644 index 00000000000..0c03e56b5b2 --- /dev/null +++ b/framework/src/Volo.Abp.AspNetCore.Components.WebAssembly/Volo/Abp/AspNetCore/Components/WebAssembly/RouteBasedCultureUrlHelper.cs @@ -0,0 +1,90 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using Volo.Abp.AspNetCore.Mvc.Client; +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.AspNetCore.Components.WebAssembly; + +public class RouteBasedCultureUrlHelper : IRouteBasedCultureUrlHelper, ITransientDependency +{ + private readonly ICachedApplicationConfigurationClient _configurationClient; + + public RouteBasedCultureUrlHelper(ICachedApplicationConfigurationClient configurationClient) + { + _configurationClient = configurationClient; + } + + public virtual async Task 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; + } + + /// + /// Returns the first path segment of , + /// stripping any query string or fragment before splitting on '/'. + /// For example: "zh-Hans/account?x=1" → "zh-Hans", "tr/home#top" → "tr". + /// + 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; + } +} diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc.Contracts/Volo/Abp/AspNetCore/Mvc/ApplicationConfigurations/ApplicationLocalizationConfigurationDto.cs b/framework/src/Volo.Abp.AspNetCore.Mvc.Contracts/Volo/Abp/AspNetCore/Mvc/ApplicationConfigurations/ApplicationLocalizationConfigurationDto.cs index 5ae2a8ed2d3..1f3bbe96a3d 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc.Contracts/Volo/Abp/AspNetCore/Mvc/ApplicationConfigurations/ApplicationLocalizationConfigurationDto.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc.Contracts/Volo/Abp/AspNetCore/Mvc/ApplicationConfigurations/ApplicationLocalizationConfigurationDto.cs @@ -34,6 +34,8 @@ public class ApplicationLocalizationConfigurationDto public Dictionary> LanguageFilesMap { get; set; } + public bool UseRouteBasedCulture { get; set; } + public ApplicationLocalizationConfigurationDto() { Values = new Dictionary>(); diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AbpAspNetCoreMvcModule.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AbpAspNetCoreMvcModule.cs index ad8d8c1c28c..9da7e7454f8 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AbpAspNetCoreMvcModule.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AbpAspNetCoreMvcModule.cs @@ -16,6 +16,8 @@ using Microsoft.AspNetCore.Mvc.DataAnnotations; using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.RazorPages.Infrastructure; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.RequestLocalization; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Localization; @@ -212,6 +214,45 @@ public override void PostConfigureServices(ServiceConfigurationContext context) { preConfigureActions.Configure(options); }); + + ConfigureRouteBasedCulture(context); + } + + protected virtual void ConfigureRouteBasedCulture(ServiceConfigurationContext context) + { + context.Services.Configure(options => + { + options.ConstraintMap["culture"] = typeof(AbpCultureRouteConstraint); + }); + + context.Services + .AddOptions() + .PostConfigure>((routerOptions, abpLocOptions) => + { + if (abpLocOptions.Value.UseRouteBasedCulture) + { + routerOptions.EndpointConfigureActions.Insert(0, endpointContext => + { + endpointContext.Endpoints.MapControllerRoute( + "AbpCultureRoute", + AbpCultureRoutePagesConvention.CultureRouteTemplate + "/{controller=Home}/{action=Index}/{id?}"); + }); + } + }); + + context.Services + .AddOptions() + .PostConfigure>((pagesOptions, abpLocOptions) => + { + if (abpLocOptions.Value.UseRouteBasedCulture && + !pagesOptions.Conventions.OfType().Any()) + { + pagesOptions.Conventions.Add(new AbpCultureRoutePagesConvention()); + } + }); + + context.Services.TryAddSingleton(); + context.Services.Replace(ServiceDescriptor.Singleton()); } public override void OnApplicationInitialization(ApplicationInitializationContext context) diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApplicationConfigurations/AbpApplicationConfigurationAppService.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApplicationConfigurations/AbpApplicationConfigurationAppService.cs index b1ae63c39ba..f22b7739a2e 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApplicationConfigurations/AbpApplicationConfigurationAppService.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApplicationConfigurations/AbpApplicationConfigurationAppService.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.RequestLocalization; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Localization; using Microsoft.Extensions.Options; @@ -27,6 +28,7 @@ namespace Volo.Abp.AspNetCore.Mvc.ApplicationConfigurations; public class AbpApplicationConfigurationAppService : ApplicationService, IAbpApplicationConfigurationAppService { private readonly AbpLocalizationOptions _localizationOptions; + private readonly AbpRequestLocalizationOptions _requestLocalizationOptions; private readonly AbpMultiTenancyOptions _multiTenancyOptions; private readonly IServiceProvider _serviceProvider; private readonly IAbpAuthorizationPolicyProvider _abpAuthorizationPolicyProvider; @@ -46,6 +48,7 @@ public class AbpApplicationConfigurationAppService : ApplicationService, IAbpApp public AbpApplicationConfigurationAppService( IOptions localizationOptions, + IOptions requestLocalizationOptions, IOptions multiTenancyOptions, IServiceProvider serviceProvider, IAbpAuthorizationPolicyProvider abpAuthorizationPolicyProvider, @@ -79,6 +82,7 @@ public AbpApplicationConfigurationAppService( _cachedObjectExtensionsDtoService = cachedObjectExtensionsDtoService; _options = options.Value; _localizationOptions = localizationOptions.Value; + _requestLocalizationOptions = requestLocalizationOptions.Value; _multiTenancyOptions = multiTenancyOptions.Value; } @@ -253,6 +257,7 @@ await LazyServiceProvider localizationConfig.LanguagesMap = _localizationOptions.LanguagesMap; localizationConfig.LanguageFilesMap = _localizationOptions.LanguageFilesMap; + localizationConfig.UseRouteBasedCulture = _requestLocalizationOptions.UseRouteBasedCulture; return localizationConfig; } diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpAspNetCoreMvcQueryStringCultureReplacement.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpAspNetCoreMvcQueryStringCultureReplacement.cs index 688392f3f13..14a9ba5c510 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpAspNetCoreMvcQueryStringCultureReplacement.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpAspNetCoreMvcQueryStringCultureReplacement.cs @@ -1,6 +1,10 @@ -using System; +using System; +using System.Collections.Generic; +using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.WebUtilities; using Volo.Abp.DependencyInjection; namespace Volo.Abp.AspNetCore.Mvc.Localization; @@ -9,25 +13,71 @@ public class AbpAspNetCoreMvcQueryStringCultureReplacement : IQueryStringCulture { public virtual Task ReplaceAsync(QueryStringCultureReplacementContext context) { - if (!string.IsNullOrWhiteSpace(context.ReturnUrl)) + if (string.IsNullOrWhiteSpace(context.ReturnUrl)) { - if (context.ReturnUrl.Contains("culture=", StringComparison.OrdinalIgnoreCase) && - context.ReturnUrl.Contains("ui-Culture=", StringComparison.OrdinalIgnoreCase)) - { - context.ReturnUrl = Regex.Replace( - context.ReturnUrl, - "culture=[A-Za-z-]+", - $"culture={context.RequestCulture.Culture}", - RegexOptions.Compiled | RegexOptions.IgnoreCase); - - context.ReturnUrl = Regex.Replace( - context.ReturnUrl, - "ui-culture=[A-Za-z-]+", - $"ui-culture={context.RequestCulture.UICulture}", - RegexOptions.Compiled | RegexOptions.IgnoreCase); - } + return Task.CompletedTask; } + var currentCulture = context.CurrentCulture + ?? context.HttpContext.GetRouteValue("culture")?.ToString(); + + if (!string.IsNullOrEmpty(currentCulture)) + { + var escapedCulture = Regex.Escape(currentCulture); + // Replace only the first occurrence so that paths like /en/products/en/details + // only have the leading culture segment replaced, while tenant-prefixed paths + // like /tenant-a/en/... are also handled correctly. + var pattern = $"/{escapedCulture}(?=/|$|\\?|#)"; + context.ReturnUrl = new Regex(pattern, RegexOptions.IgnoreCase) + .Replace(context.ReturnUrl, "/" + context.RequestCulture.Culture.Name, 1); + } + + context.ReturnUrl = ReplaceQueryStringCulture(context.ReturnUrl, context); + return Task.CompletedTask; } + + /// + /// Replaces culture and ui-culture query parameters in + /// with the values from . Each parameter is handled independently — + /// the presence of one does not require the other. Uses a proper query parser instead of + /// regex to avoid false-positive matches inside other parameter values. + /// + protected virtual string ReplaceQueryStringCulture(string url, QueryStringCultureReplacementContext context) + { + var fragmentIndex = url.IndexOf('#'); + var fragment = fragmentIndex >= 0 ? url.Substring(fragmentIndex) : string.Empty; + var urlWithoutFragment = fragmentIndex >= 0 ? url.Substring(0, fragmentIndex) : url; + + var queryIndex = urlWithoutFragment.IndexOf('?'); + if (queryIndex < 0) + { + return url; + } + + var path = urlWithoutFragment.Substring(0, queryIndex); + var queryString = urlWithoutFragment.Substring(queryIndex); + var query = QueryHelpers.ParseQuery(queryString); + + if (!query.ContainsKey("culture") && !query.ContainsKey("ui-culture")) + { + return url; + } + + if (query.ContainsKey("culture")) + { + query["culture"] = context.RequestCulture.Culture.Name; + } + + if (query.ContainsKey("ui-culture")) + { + query["ui-culture"] = context.RequestCulture.UICulture.Name; + } + + var rebuiltUrl = QueryHelpers.AddQueryString( + path, + query.SelectMany(kvp => kvp.Value.Select(v => KeyValuePair.Create(kvp.Key, v)))); + + return rebuiltUrl + fragment; + } } diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureAwareUrlHelper.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureAwareUrlHelper.cs new file mode 100644 index 00000000000..35c8dedeeff --- /dev/null +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureAwareUrlHelper.cs @@ -0,0 +1,71 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.Routing; + +namespace Volo.Abp.AspNetCore.Mvc.Localization; + +/// +/// Wraps an to automatically inject the culture route value +/// into all URL generation calls. +/// +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, + }); + } +} diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureMenuItemUrlProvider.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureMenuItemUrlProvider.cs new file mode 100644 index 00000000000..1b91ce72e35 --- /dev/null +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureMenuItemUrlProvider.cs @@ -0,0 +1,69 @@ +using System; +using System.Globalization; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.RequestLocalization; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Options; +using Volo.Abp.DependencyInjection; +using Volo.Abp.Localization; +using Volo.Abp.UI.Navigation; + +namespace Volo.Abp.AspNetCore.Mvc.Localization; + +/// +/// Prepends the culture route prefix to menu item URLs when route-based culture is enabled. +/// +public class AbpCultureMenuItemUrlProvider : IMenuItemUrlProvider, ITransientDependency +{ + protected IHttpContextAccessor HttpContextAccessor { get; } + protected IOptions LocalizationOptions { get; } + protected IOptions AbpLocalizationOptions { get; } + protected IMenuItemCulturePrefixHelper MenuItemCulturePrefixHelper { get; } + + public AbpCultureMenuItemUrlProvider( + IHttpContextAccessor httpContextAccessor, + IOptions localizationOptions, + IOptions 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; + } + +} diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureRouteConstraint.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureRouteConstraint.cs new file mode 100644 index 00000000000..fe12bac3fff --- /dev/null +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureRouteConstraint.cs @@ -0,0 +1,37 @@ +using System; +using System.Linq; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Volo.Abp.Localization; + +namespace Volo.Abp.AspNetCore.Mvc.Localization; + +/// +/// A route constraint that only matches culture values configured in +/// . +/// +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>()?.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)); + } +} diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureRoutePagesConvention.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureRoutePagesConvention.cs new file mode 100644 index 00000000000..9410562f332 --- /dev/null +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureRoutePagesConvention.cs @@ -0,0 +1,47 @@ +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Mvc.ApplicationModels; + +namespace Volo.Abp.AspNetCore.Mvc.Localization; + +/// +/// Adds a {culture}-prefixed route selector to every Razor Page. +/// Automatically registered when UseRouteBasedCulture is true. +/// +public class AbpCultureRoutePagesConvention : IPageRouteModelConvention +{ + /// + /// Route parameter template using the custom "culture" route constraint registered in + /// . The constraint only matches culture values + /// configured in . + /// + internal const string CultureRouteTemplate = "{culture:culture}"; + + public virtual void Apply(PageRouteModel model) + { + var selectorsToAdd = new List(); + + 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); + } + } +} diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureRouteUrlHelperFactory.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureRouteUrlHelperFactory.cs new file mode 100644 index 00000000000..7be44566771 --- /dev/null +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureRouteUrlHelperFactory.cs @@ -0,0 +1,49 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.RequestLocalization; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Options; + +namespace Volo.Abp.AspNetCore.Mvc.Localization; + +/// +/// Wraps the default to automatically inject the culture +/// route value into all URL generation calls when the current request has a {culture} route value. +/// Only activates when is true. +/// +public class AbpCultureRouteUrlHelperFactory : IUrlHelperFactory +{ + protected UrlHelperFactory Inner { get; } + protected IOptions LocalizationOptions { get; } + + public AbpCultureRouteUrlHelperFactory( + UrlHelperFactory inner, + IOptions 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); + } +} diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpLanguagesController.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpLanguagesController.cs index 73c93851ff2..dc5dd3ccf6e 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpLanguagesController.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpLanguagesController.cs @@ -1,7 +1,8 @@ -using Microsoft.AspNetCore.Localization; -using Microsoft.AspNetCore.Mvc; using System; +using System.Linq; using System.Threading.Tasks; +using Microsoft.AspNetCore.Localization; +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RequestLocalization; using Volo.Abp.Auditing; using Volo.Abp.Localization; @@ -42,7 +43,12 @@ public virtual async Task Switch(string culture, string uiCulture HttpContext.Items[AbpRequestLocalizationMiddleware.HttpContextItemName] = true; - var context = new QueryStringCultureReplacementContext(HttpContext, new RequestCulture(culture, uiCulture), returnUrl); + var context = new QueryStringCultureReplacementContext( + HttpContext, + new RequestCulture(culture, uiCulture), + returnUrl, + GetCurrentCultureFromRequestCookie()); + await QueryStringCultureReplacement.ReplaceAsync(context); if (!string.IsNullOrWhiteSpace(context.ReturnUrl)) @@ -53,6 +59,18 @@ public virtual async Task Switch(string culture, string uiCulture return Redirect("~/"); } + protected virtual string? GetCurrentCultureFromRequestCookie() + { + var cookieValue = HttpContext.Request.Cookies[CookieRequestCultureProvider.DefaultCookieName]; + if (cookieValue == null) + { + return null; + } + + var result = CookieRequestCultureProvider.ParseCookieValue(cookieValue); + return result?.Cultures.FirstOrDefault().Value; + } + protected virtual string GetRedirectUrl(string returnUrl) { if (returnUrl.IsNullOrEmpty()) diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/QueryStringCultureReplacementContext.cs b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/QueryStringCultureReplacementContext.cs index b70c24fb957..15866f54bc9 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/QueryStringCultureReplacementContext.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/QueryStringCultureReplacementContext.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Localization; namespace Volo.Abp.AspNetCore.Mvc.Localization; @@ -11,10 +11,17 @@ public class QueryStringCultureReplacementContext public string ReturnUrl { get; set; } - public QueryStringCultureReplacementContext(HttpContext httpContext, RequestCulture requestCulture, string returnUrl) + public string? CurrentCulture { get; } + + public QueryStringCultureReplacementContext( + HttpContext httpContext, + RequestCulture requestCulture, + string returnUrl, + string? currentCulture = null) { HttpContext = httpContext; RequestCulture = requestCulture; ReturnUrl = returnUrl; + CurrentCulture = currentCulture; } } diff --git a/framework/src/Volo.Abp.AspNetCore/Microsoft/AspNetCore/RequestLocalization/AbpRequestCultureCookieHelper.cs b/framework/src/Volo.Abp.AspNetCore/Microsoft/AspNetCore/RequestLocalization/AbpRequestCultureCookieHelper.cs index 9bb5b61d283..552c3bfc846 100644 --- a/framework/src/Volo.Abp.AspNetCore/Microsoft/AspNetCore/RequestLocalization/AbpRequestCultureCookieHelper.cs +++ b/framework/src/Volo.Abp.AspNetCore/Microsoft/AspNetCore/RequestLocalization/AbpRequestCultureCookieHelper.cs @@ -1,11 +1,41 @@ using System; +using System.Globalization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Localization; +using Microsoft.AspNetCore.Routing; namespace Microsoft.AspNetCore.RequestLocalization; public static class AbpRequestCultureCookieHelper { + public const string HasRouteCultureCookieName = "Abp.HasRouteCulture"; + + /// + /// Gets the current route culture from the request. First checks route values, + /// then falls back to the HasRouteCulture cookie (set during Blazor SSR) with CurrentCulture. + /// Returns null if the request has no route-based culture. + /// + public static string? GetRouteCulture(HttpContext? httpContext) + { + if (httpContext == null) + { + return null; + } + + var routeCulture = httpContext.GetRouteValue("culture")?.ToString(); + if (!string.IsNullOrEmpty(routeCulture)) + { + return routeCulture; + } + + if (httpContext.Request.Cookies.ContainsKey(HasRouteCultureCookieName)) + { + return CultureInfo.CurrentCulture.Name; + } + + return null; + } + public static void SetCultureCookie( HttpContext httpContext, RequestCulture requestCulture) @@ -20,4 +50,21 @@ public static void SetCultureCookie( } ); } + + public static void SetHasRouteCultureCookie(HttpContext httpContext, bool hasRouteCulture) + { + if (hasRouteCulture) + { + httpContext.Response.Cookies.Append( + HasRouteCultureCookieName, "1", + new CookieOptions + { + IsEssential = true + }); + } + else + { + httpContext.Response.Cookies.Delete(HasRouteCultureCookieName); + } + } } diff --git a/framework/src/Volo.Abp.AspNetCore/Microsoft/AspNetCore/RequestLocalization/AbpRequestLocalizationMiddleware.cs b/framework/src/Volo.Abp.AspNetCore/Microsoft/AspNetCore/RequestLocalization/AbpRequestLocalizationMiddleware.cs index cb531762ba1..4683c3151de 100644 --- a/framework/src/Volo.Abp.AspNetCore/Microsoft/AspNetCore/RequestLocalization/AbpRequestLocalizationMiddleware.cs +++ b/framework/src/Volo.Abp.AspNetCore/Microsoft/AspNetCore/RequestLocalization/AbpRequestLocalizationMiddleware.cs @@ -1,7 +1,9 @@ -using System.Threading.Tasks; +using System.Linq; +using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Localization; +using Microsoft.AspNetCore.Localization.Routing; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Volo.Abp.AspNetCore.Middleware; @@ -39,13 +41,28 @@ await _requestLocalizationOptionsProvider.GetLocalizationOptionsAsync() if (context.Items[HttpContextItemName] == null) { var requestCultureFeature = context.Features.Get(); - if (requestCultureFeature?.Provider is QueryStringRequestCultureProvider) + if (requestCultureFeature?.Provider is QueryStringRequestCultureProvider + or RouteDataRequestCultureProvider) { AbpRequestCultureCookieHelper.SetCultureCookie( context, requestCultureFeature.RequestCulture ); } + + // Only manage HasRouteCulture cookie for Blazor component page requests. + // This cookie is used by AbpCultureMenuItemUrlProvider to determine if the + // initial SSR page had a culture prefix, since the Blazor interactive circuit + // (/_blazor) does not carry the original route values. + // Note: ComponentTypeMetadata is an internal ASP.NET Core type + // (Microsoft.AspNetCore.Components.Endpoints.ComponentTypeMetadata). + // We match by full type name to avoid false positives from other assemblies. + var endpoint = context.GetEndpoint(); + if (endpoint?.Metadata.Any(m => m.GetType().FullName == "Microsoft.AspNetCore.Components.Endpoints.ComponentTypeMetadata") == true) + { + AbpRequestCultureCookieHelper.SetHasRouteCultureCookie( + context, requestCultureFeature?.Provider is RouteDataRequestCultureProvider); + } } return Task.CompletedTask; diff --git a/framework/src/Volo.Abp.AspNetCore/Microsoft/AspNetCore/RequestLocalization/AbpRequestLocalizationOptions.cs b/framework/src/Volo.Abp.AspNetCore/Microsoft/AspNetCore/RequestLocalization/AbpRequestLocalizationOptions.cs index bb83ce9a539..55394b7d17c 100644 --- a/framework/src/Volo.Abp.AspNetCore/Microsoft/AspNetCore/RequestLocalization/AbpRequestLocalizationOptions.cs +++ b/framework/src/Volo.Abp.AspNetCore/Microsoft/AspNetCore/RequestLocalization/AbpRequestLocalizationOptions.cs @@ -9,6 +9,12 @@ public class AbpRequestLocalizationOptions { public List> RequestLocalizationOptionConfigurators { get; } + /// + /// Enables culture detection from route data (e.g. /{culture}/page). + /// Default value: false. + /// + public bool UseRouteBasedCulture { get; set; } + public AbpRequestLocalizationOptions() { RequestLocalizationOptionConfigurators = new List>(); diff --git a/framework/src/Volo.Abp.AspNetCore/Microsoft/AspNetCore/RequestLocalization/DefaultAbpRequestLocalizationOptionsProvider.cs b/framework/src/Volo.Abp.AspNetCore/Microsoft/AspNetCore/RequestLocalization/DefaultAbpRequestLocalizationOptionsProvider.cs index cf9f128852d..3cd2e889938 100644 --- a/framework/src/Volo.Abp.AspNetCore/Microsoft/AspNetCore/RequestLocalization/DefaultAbpRequestLocalizationOptionsProvider.cs +++ b/framework/src/Volo.Abp.AspNetCore/Microsoft/AspNetCore/RequestLocalization/DefaultAbpRequestLocalizationOptionsProvider.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Localization; +using Microsoft.AspNetCore.Localization.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Volo.Abp.DependencyInjection; @@ -70,9 +71,18 @@ public virtual async Task GetLocalizationOptionsAsyn .ToArray() }; - foreach (var configurator in serviceScope.ServiceProvider - .GetRequiredService>() - .Value.RequestLocalizationOptionConfigurators) + var abpRequestLocalizationOptions = serviceScope.ServiceProvider + .GetRequiredService>() + .Value; + + if (abpRequestLocalizationOptions.UseRouteBasedCulture) + { + options.RequestCultureProviders.InsertAfter( + p => p is QueryStringRequestCultureProvider, + new RouteDataRequestCultureProvider()); + } + + foreach (var configurator in abpRequestLocalizationOptions.RequestLocalizationOptionConfigurators) { await configurator(serviceScope.ServiceProvider, options); } diff --git a/framework/src/Volo.Abp.UI.Navigation/Volo/Abp/Ui/Navigation/IMenuItemCulturePrefixHelper.cs b/framework/src/Volo.Abp.UI.Navigation/Volo/Abp/Ui/Navigation/IMenuItemCulturePrefixHelper.cs new file mode 100644 index 00000000000..93d9992b3fd --- /dev/null +++ b/framework/src/Volo.Abp.UI.Navigation/Volo/Abp/Ui/Navigation/IMenuItemCulturePrefixHelper.cs @@ -0,0 +1,8 @@ +using System.Threading.Tasks; + +namespace Volo.Abp.UI.Navigation; + +public interface IMenuItemCulturePrefixHelper +{ + Task PrependCulturePrefixAsync(IHasMenuItems menuWithItems, string prefix); +} diff --git a/framework/src/Volo.Abp.UI.Navigation/Volo/Abp/Ui/Navigation/IMenuItemUrlProvider.cs b/framework/src/Volo.Abp.UI.Navigation/Volo/Abp/Ui/Navigation/IMenuItemUrlProvider.cs new file mode 100644 index 00000000000..faae8c2367c --- /dev/null +++ b/framework/src/Volo.Abp.UI.Navigation/Volo/Abp/Ui/Navigation/IMenuItemUrlProvider.cs @@ -0,0 +1,13 @@ +using System.Threading.Tasks; + +namespace Volo.Abp.UI.Navigation; + +/// +/// 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). +/// +public interface IMenuItemUrlProvider +{ + Task HandleAsync(MenuItemUrlProviderContext context); +} diff --git a/framework/src/Volo.Abp.UI.Navigation/Volo/Abp/Ui/Navigation/MenuItemCulturePrefixHelper.cs b/framework/src/Volo.Abp.UI.Navigation/Volo/Abp/Ui/Navigation/MenuItemCulturePrefixHelper.cs new file mode 100644 index 00000000000..242cf9f6790 --- /dev/null +++ b/framework/src/Volo.Abp.UI.Navigation/Volo/Abp/Ui/Navigation/MenuItemCulturePrefixHelper.cs @@ -0,0 +1,33 @@ +using System.Threading.Tasks; +using Volo.Abp.DependencyInjection; + +namespace Volo.Abp.UI.Navigation; + +public class MenuItemCulturePrefixHelper : IMenuItemCulturePrefixHelper, ITransientDependency +{ + public virtual Task PrependCulturePrefixAsync(IHasMenuItems menuWithItems, string prefix) + { + PrependCulturePrefix(menuWithItems, prefix); + return Task.CompletedTask; + } + + protected virtual void PrependCulturePrefix(IHasMenuItems menuWithItems, string prefix) + { + foreach (var item in menuWithItems.Items) + { + if (item.Url != null) + { + if (item.Url.StartsWith("~/")) + { + item.Url = "~" + prefix + item.Url.Substring(1); + } + else if (item.Url.StartsWith("/")) + { + item.Url = prefix + item.Url; + } + } + + PrependCulturePrefix(item, prefix); + } + } +} diff --git a/framework/src/Volo.Abp.UI.Navigation/Volo/Abp/Ui/Navigation/MenuItemUrlProviderContext.cs b/framework/src/Volo.Abp.UI.Navigation/Volo/Abp/Ui/Navigation/MenuItemUrlProviderContext.cs new file mode 100644 index 00000000000..55f9d178dac --- /dev/null +++ b/framework/src/Volo.Abp.UI.Navigation/Volo/Abp/Ui/Navigation/MenuItemUrlProviderContext.cs @@ -0,0 +1,11 @@ +namespace Volo.Abp.UI.Navigation; + +public class MenuItemUrlProviderContext +{ + public ApplicationMenu Menu { get; } + + public MenuItemUrlProviderContext(ApplicationMenu menu) + { + Menu = menu; + } +} diff --git a/framework/src/Volo.Abp.UI.Navigation/Volo/Abp/Ui/Navigation/MenuManager.cs b/framework/src/Volo.Abp.UI.Navigation/Volo/Abp/Ui/Navigation/MenuManager.cs index 6334ea99186..f1d8ae22d7e 100644 --- a/framework/src/Volo.Abp.UI.Navigation/Volo/Abp/Ui/Navigation/MenuManager.cs +++ b/framework/src/Volo.Abp.UI.Navigation/Volo/Abp/Ui/Navigation/MenuManager.cs @@ -92,6 +92,12 @@ protected virtual async Task GetInternalAsync(string name) await CheckPermissionsAsync(scope.ServiceProvider, menu); } + + var urlProviderContext = new MenuItemUrlProviderContext(menu); + foreach (var urlProvider in scope.ServiceProvider.GetServices()) + { + await urlProvider.HandleAsync(urlProviderContext); + } } NormalizeMenu(menu); diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo.Abp.AspNetCore.Mvc.Tests.csproj b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo.Abp.AspNetCore.Mvc.Tests.csproj index 8a929dd38aa..496dba12edf 100644 --- a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo.Abp.AspNetCore.Mvc.Tests.csproj +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo.Abp.AspNetCore.Mvc.Tests.csproj @@ -24,6 +24,7 @@ + diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/AbpAspNetCoreMvcTestModule.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/AbpAspNetCoreMvcTestModule.cs index 91546086809..4339104b301 100644 --- a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/AbpAspNetCoreMvcTestModule.cs +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/AbpAspNetCoreMvcTestModule.cs @@ -11,6 +11,7 @@ using Volo.Abp.AspNetCore.Mvc.Libs; using Volo.Abp.AspNetCore.Mvc.Localization; using Volo.Abp.AspNetCore.Mvc.Localization.Resource; +using Microsoft.AspNetCore.RequestLocalization; using Volo.Abp.AspNetCore.Security.Claims; using Volo.Abp.AspNetCore.TestBase; using Volo.Abp.Authorization; @@ -121,6 +122,11 @@ public override void ConfigureServices(ServiceConfigurationContext context) options.Languages.Add(new LanguageInfo("el", "el", "Ελληνικά")); }); + Configure(options => + { + options.UseRouteBasedCulture = true; + }); + Configure(options => { options.RootDirectory = "/Volo/Abp/AspNetCore/Mvc"; diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureAwareUrlHelper_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureAwareUrlHelper_Tests.cs new file mode 100644 index 00000000000..d761d85bf84 --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureAwareUrlHelper_Tests.cs @@ -0,0 +1,137 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Routing; +using Microsoft.AspNetCore.RequestLocalization; +using Microsoft.AspNetCore.Routing; +using NSubstitute; +using Shouldly; +using Volo.Abp.AspNetCore.Mvc.Localization; +using Xunit; + +namespace Volo.Abp.AspNetCore.Mvc.Localization; + +public class AbpCultureAwareUrlHelper_Tests +{ + [Fact] + public void Action_Should_Inject_Culture() + { + var inner = Substitute.For(); + inner.Action(Arg.Any()).Returns(callInfo => + { + var ctx = callInfo.Arg(); + 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(); + inner.Action(Arg.Any()).Returns(callInfo => + { + var ctx = callInfo.Arg(); + 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(); + inner.RouteUrl(Arg.Any()).Returns(callInfo => + { + var ctx = callInfo.Arg(); + 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(); + 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(); + 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(); + } + + [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(); + } + + [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(); + } + + private static AbpCultureRouteUrlHelperFactory CreateFactory(bool useRouteBasedCulture) + { + return new AbpCultureRouteUrlHelperFactory( + new UrlHelperFactory(), + Microsoft.Extensions.Options.Options.Create(new AbpRequestLocalizationOptions { UseRouteBasedCulture = useRouteBasedCulture })); + } +} diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureMenuItemUrlProvider_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureMenuItemUrlProvider_Tests.cs new file mode 100644 index 00000000000..bd77480bc4f --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureMenuItemUrlProvider_Tests.cs @@ -0,0 +1,280 @@ +using System; +using System.Globalization; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.RequestLocalization; +using Microsoft.AspNetCore.Routing; +using Shouldly; +using MsOptions = Microsoft.Extensions.Options.Options; +using Volo.Abp.Localization; +using Volo.Abp.UI.Navigation; +using Xunit; + +namespace Volo.Abp.AspNetCore.Mvc.Localization; + +public class AbpCultureMenuItemUrlProvider_Tests +{ + [Fact] + public async Task Should_Not_Modify_Urls_When_RouteBasedCulture_Is_Disabled() + { + var provider = CreateProvider(useRouteBasedCulture: false, cultureName: "zh-Hans"); + var menu = CreateMenuWithItems("/home", "/about"); + + await provider.HandleAsync(new MenuItemUrlProviderContext(menu)); + + menu.Items[0].Url.ShouldBe("/home"); + menu.Items[1].Url.ShouldBe("/about"); + } + + [Fact] + public async Task Should_Prepend_Culture_Prefix_When_Route_Has_Culture() + { + var provider = CreateProvider(useRouteBasedCulture: true, cultureName: "zh-Hans"); + var menu = CreateMenuWithItems("/home", "/about"); + + await provider.HandleAsync(new MenuItemUrlProviderContext(menu)); + + menu.Items[0].Url.ShouldBe("/zh-Hans/home"); + menu.Items[1].Url.ShouldBe("/zh-Hans/about"); + } + + [Fact] + public async Task Should_Not_Add_Prefix_For_Mvc_Request_Without_Culture() + { + // MVC request to /about (no culture, no HasRouteCulture cookie). + var httpContext = new DefaultHttpContext(); + httpContext.Request.RouteValues["controller"] = "Home"; + httpContext.Request.RouteValues["action"] = "About"; + var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext }; + var localizationOptions = MsOptions.Create( + new AbpRequestLocalizationOptions { UseRouteBasedCulture = true }); + var abpLocOptions = new AbpLocalizationOptions(); + abpLocOptions.Languages.Add(new LanguageInfo("en")); + abpLocOptions.Languages.Add(new LanguageInfo("zh-Hans")); + var provider = new AbpCultureMenuItemUrlProvider( + httpContextAccessor, localizationOptions, MsOptions.Create(abpLocOptions), new MenuItemCulturePrefixHelper()); + + var menu = CreateMenuWithItems("/home", "/about"); + + var previousCulture = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo("zh-Hans"); + await provider.HandleAsync(new MenuItemUrlProviderContext(menu)); + } + finally + { + CultureInfo.CurrentCulture = previousCulture; + } + + menu.Items[0].Url.ShouldBe("/home"); + menu.Items[1].Url.ShouldBe("/about"); + } + + [Fact] + public async Task Should_Fallback_To_CurrentCulture_In_Blazor_Circuit() + { + // Blazor Server interactive circuit: HttpContext exists (SignalR) but has + // no route culture. Cookie was set during SSR indicating route culture was used. + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["Cookie"] = $"{AbpRequestCultureCookieHelper.HasRouteCultureCookieName}=1"; + var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext }; + var localizationOptions = MsOptions.Create( + new AbpRequestLocalizationOptions { UseRouteBasedCulture = true }); + var abpLocOptions = new AbpLocalizationOptions(); + abpLocOptions.Languages.Add(new LanguageInfo("en")); + abpLocOptions.Languages.Add(new LanguageInfo("zh-Hans")); + var provider = new AbpCultureMenuItemUrlProvider( + httpContextAccessor, localizationOptions, MsOptions.Create(abpLocOptions), new MenuItemCulturePrefixHelper()); + + var menu = CreateMenuWithItems("/home", "/about"); + + var previousCulture = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo("zh-Hans"); + await provider.HandleAsync(new MenuItemUrlProviderContext(menu)); + } + finally + { + CultureInfo.CurrentCulture = previousCulture; + } + + menu.Items[0].Url.ShouldBe("/zh-Hans/home"); + menu.Items[1].Url.ShouldBe("/zh-Hans/about"); + } + + [Fact] + public async Task Should_Use_CurrentCulture_Fallback_When_No_HttpContext() + { + // Simulates Blazor interactive circuit: no HttpContext, but CurrentCulture is set. + // CurrentCulture (not CurrentUICulture) is used because {culture} route segments + // represent the culture, not the UI culture. + var provider = CreateProviderWithoutHttpContext( + useRouteBasedCulture: true, + knownLanguages: new[] { "en", "zh-Hans", "tr" }); + + var menu = CreateMenuWithItems("/home", "/about"); + + var previousCulture = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo("zh-Hans"); + await provider.HandleAsync(new MenuItemUrlProviderContext(menu)); + } + finally + { + CultureInfo.CurrentCulture = previousCulture; + } + + menu.Items[0].Url.ShouldBe("/zh-Hans/home"); + menu.Items[1].Url.ShouldBe("/zh-Hans/about"); + } + + [Fact] + public async Task Should_Not_Modify_Urls_When_No_HttpContext_And_Unknown_Culture() + { + // Blazor interactive circuit with a culture that is not in the known languages list + var provider = CreateProviderWithoutHttpContext( + useRouteBasedCulture: true, + knownLanguages: new[] { "en", "tr" }); + + var menu = CreateMenuWithItems("/home", "/about"); + + var previousCulture = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo("fr"); + await provider.HandleAsync(new MenuItemUrlProviderContext(menu)); + } + finally + { + CultureInfo.CurrentCulture = previousCulture; + } + + menu.Items[0].Url.ShouldBe("/home"); + menu.Items[1].Url.ShouldBe("/about"); + } + + [Fact] + public async Task Should_Prepend_Prefix_Recursively_For_Nested_Items() + { + var provider = CreateProvider(useRouteBasedCulture: true, cultureName: "tr"); + + var menu = new ApplicationMenu("TestMenu"); + var parent = new ApplicationMenuItem("Parent", "Parent", url: "/parent"); + var child = new ApplicationMenuItem("Child", "Child", url: "/child"); + var grandChild = new ApplicationMenuItem("GrandChild", "GrandChild", url: "/grandchild"); + child.AddItem(grandChild); + parent.AddItem(child); + menu.AddItem(parent); + + await provider.HandleAsync(new MenuItemUrlProviderContext(menu)); + + parent.Url.ShouldBe("/tr/parent"); + child.Url.ShouldBe("/tr/child"); + grandChild.Url.ShouldBe("/tr/grandchild"); + } + + [Fact] + public async Task Should_Handle_Tilde_Slash_Urls() + { + // ~/identity/users is the pattern used by ABP module menu contributors (e.g. Identity) + var provider = CreateProvider(useRouteBasedCulture: true, cultureName: "zh-Hans"); + + var menu = new ApplicationMenu("TestMenu"); + menu.AddItem(new ApplicationMenuItem("Users", "Users", url: "~/identity/users")); + menu.AddItem(new ApplicationMenuItem("Roles", "Roles", url: "~/identity/roles")); + + await provider.HandleAsync(new MenuItemUrlProviderContext(menu)); + + // ~/identity/users → ~/zh-Hans/identity/users + // Blazor theme strips "~/" via TrimStart('/', '~') → "zh-Hans/identity/users" + // With 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; + } + +} diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/AbpLanguagesController_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/AbpLanguagesController_Tests.cs new file mode 100644 index 00000000000..491577c43ea --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/AbpLanguagesController_Tests.cs @@ -0,0 +1,123 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Localization; +using Shouldly; +using Xunit; + +namespace Volo.Abp.AspNetCore.Mvc.Localization; + +public class AbpLanguagesController_Tests : AspNetCoreMvcTestBase +{ + private const string SwitchUrl = "/Abp/Languages/Switch"; + + [Fact] + public async Task Should_Replace_Route_Culture_In_ReturnUrl_When_Cookie_Is_Set() + { + var response = await SendSwitchRequestAsync( + targetCulture: "zh-Hans", + returnUrl: "/en/Home/About", + currentCultureCookie: "en"); + + response.StatusCode.ShouldBe(HttpStatusCode.Found); + response.Headers.Location?.ToString().ShouldBe("/zh-Hans/Home/About"); + } + + [Fact] + public async Task Should_Replace_Route_Culture_When_Switching_Back() + { + var response = await SendSwitchRequestAsync( + targetCulture: "en", + returnUrl: "/zh-Hans/About", + currentCultureCookie: "zh-Hans"); + + response.StatusCode.ShouldBe(HttpStatusCode.Found); + response.Headers.Location?.ToString().ShouldBe("/en/About"); + } + + [Fact] + public async Task Should_Replace_Region_Culture_In_ReturnUrl() + { + var response = await SendSwitchRequestAsync( + targetCulture: "zh-Hans", + returnUrl: "/en-US/products", + currentCultureCookie: "en-US"); + + response.StatusCode.ShouldBe(HttpStatusCode.Found); + response.Headers.Location?.ToString().ShouldBe("/zh-Hans/products"); + } + + [Fact] + public async Task Should_Not_Replace_When_No_Cookie() + { + // No cookie — GetCurrentCultureFromRequestCookie returns null, no route replacement + var response = await SendSwitchRequestAsync( + targetCulture: "zh-Hans", + returnUrl: "/en/Home/About", + currentCultureCookie: null); + + response.StatusCode.ShouldBe(HttpStatusCode.Found); + response.Headers.Location?.ToString().ShouldBe("/en/Home/About"); + } + + [Fact] + public async Task Should_Redirect_To_Root_When_ReturnUrl_Is_Empty() + { + var response = await SendSwitchRequestAsync( + targetCulture: "zh-Hans", + returnUrl: "", + currentCultureCookie: "en"); + + response.StatusCode.ShouldBe(HttpStatusCode.Found); + response.Headers.Location?.ToString().ShouldStartWith("/"); + } + + [Fact] + public async Task Should_Not_Replace_Culture_Inside_Longer_Segment_Via_Http() + { + // "en" must not corrupt "/enterprise/products" + var response = await SendSwitchRequestAsync( + targetCulture: "zh-Hans", + returnUrl: "/enterprise/products", + currentCultureCookie: "en"); + + response.StatusCode.ShouldBe(HttpStatusCode.Found); + response.Headers.Location?.ToString().ShouldBe("/enterprise/products"); + } + + [Fact] + public async Task Should_Replace_Culture_After_Tenant_Segment() + { + // Multi-tenant URL: /tenant-a/zh-Hans/About → /tenant-a/en/About + var response = await SendSwitchRequestAsync( + targetCulture: "en", + returnUrl: "/tenant-a/zh-Hans/About", + currentCultureCookie: "zh-Hans"); + + response.StatusCode.ShouldBe(HttpStatusCode.Found); + response.Headers.Location?.ToString().ShouldBe("/tenant-a/en/About"); + } + + private async Task 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); + } +} diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/LanguageSwitchRouteCultureReplacement_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/LanguageSwitchRouteCultureReplacement_Tests.cs new file mode 100644 index 00000000000..1c5fd4cb228 --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/LanguageSwitchRouteCultureReplacement_Tests.cs @@ -0,0 +1,259 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Localization; +using Shouldly; +using Volo.Abp.AspNetCore.Mvc.Localization; +using Xunit; + +namespace Volo.Abp.AspNetCore.Mvc.Localization; + +public class LanguageSwitchRouteCultureReplacement_Tests +{ + private readonly AbpAspNetCoreMvcQueryStringCultureReplacement _replacement = new(); + + [Fact] + public async Task Should_Replace_Route_Prefix() + { + var context = CreateContext("tr", "en", "/tr/products"); + await _replacement.ReplaceAsync(context); + context.ReturnUrl.ShouldBe("/en/products"); + } + + [Fact] + public async Task Should_Replace_Region_Culture() + { + var context = CreateContext("en-US", "zh-Hans", "/en-US/about"); + await _replacement.ReplaceAsync(context); + context.ReturnUrl.ShouldBe("/zh-Hans/about"); + } + + [Fact] + public async Task Should_Replace_Culture_Only_Url() + { + var context = CreateContext("tr", "en", "/tr"); + await _replacement.ReplaceAsync(context); + context.ReturnUrl.ShouldBe("/en"); + } + + [Fact] + public async Task Should_Replace_Culture_With_Query_String() + { + var context = CreateContext("tr", "en", "/tr?returnUrl=/home"); + await _replacement.ReplaceAsync(context); + context.ReturnUrl.ShouldBe("/en?returnUrl=/home"); + } + + [Fact] + public async Task Should_Replace_Culture_After_Tenant() + { + var context = CreateContext("zh-Hans", "en", "/tenant-a/zh-Hans/About"); + await _replacement.ReplaceAsync(context); + context.ReturnUrl.ShouldBe("/tenant-a/en/About"); + } + + [Fact] + public async Task Should_Replace_Culture_Only_After_Tenant() + { + var context = CreateContext("tr", "en", "/tenant-a/tr"); + await _replacement.ReplaceAsync(context); + context.ReturnUrl.ShouldBe("/tenant-a/en"); + } + + [Fact] + public async Task Should_Replace_Via_RouteData_When_No_CurrentCulture() + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.RouteValues["culture"] = "tr"; + var context = new QueryStringCultureReplacementContext( + httpContext, new RequestCulture("en"), "/tr/products"); + await _replacement.ReplaceAsync(context); + context.ReturnUrl.ShouldBe("/en/products"); + } + + [Fact] + public async Task Should_Not_Replace_When_No_Culture_Source() + { + var context = new QueryStringCultureReplacementContext( + new DefaultHttpContext(), new RequestCulture("en"), "/volosoft/products"); + await _replacement.ReplaceAsync(context); + context.ReturnUrl.ShouldBe("/volosoft/products"); + } + + [Fact] + public async Task Should_Not_Replace_Culture_Inside_Longer_Segment() + { + // "en" must not match inside "enterprise" + var context = CreateContext("en", "tr", "/enterprise/products"); + await _replacement.ReplaceAsync(context); + context.ReturnUrl.ShouldBe("/enterprise/products"); + } + + [Fact] + public async Task Should_Not_Replace_Culture_When_Culture_Is_Segment_Prefix() + { + // "fr" appears at the start of "fr-zone" but is not a complete segment + var context = CreateContext("fr", "en", "/fr-zone/about"); + await _replacement.ReplaceAsync(context); + context.ReturnUrl.ShouldBe("/fr-zone/about"); + } + + [Fact] + public async Task Should_Replace_Culture_Before_Fragment() + { + var context = CreateContext("en", "tr", "/en#section"); + await _replacement.ReplaceAsync(context); + context.ReturnUrl.ShouldBe("/tr#section"); + } + + [Fact] + public async Task Should_Replace_Culture_Before_Fragment_With_Path() + { + var context = CreateContext("en", "tr", "/en/about#top"); + await _replacement.ReplaceAsync(context); + context.ReturnUrl.ShouldBe("/tr/about#top"); + } + + [Fact] + public async Task Should_Replace_Query_String_Culture() + { + var context = new QueryStringCultureReplacementContext( + new DefaultHttpContext(), new RequestCulture("en", "en"), "/home?culture=tr&ui-culture=tr"); + await _replacement.ReplaceAsync(context); + context.ReturnUrl.ShouldBe("/home?culture=en&ui-culture=en"); + } + + [Fact] + public async Task Should_Replace_Both_Route_And_Query_String() + { + var context = CreateContext("tr", "en", "/tr/home?culture=tr&ui-culture=tr"); + await _replacement.ReplaceAsync(context); + context.ReturnUrl.ShouldBe("/en/home?culture=en&ui-culture=en"); + } + + [Fact] + public async Task Should_Handle_Null_ReturnUrl() + { + var context = new QueryStringCultureReplacementContext( + new DefaultHttpContext(), new RequestCulture("en"), null!); + await _replacement.ReplaceAsync(context); + context.ReturnUrl.ShouldBeNull(); + } + + [Fact] + public async Task Should_Handle_Empty_ReturnUrl() + { + var context = new QueryStringCultureReplacementContext( + new DefaultHttpContext(), new RequestCulture("en"), ""); + await _replacement.ReplaceAsync(context); + context.ReturnUrl.ShouldBe(""); + } + + [Fact] + public async Task Should_Not_Replace_When_CurrentCulture_Not_In_ReturnUrl() + { + // currentCulture is "fr" but returnUrl has no "/fr" segment + var context = CreateContext("fr", "en", "/about"); + await _replacement.ReplaceAsync(context); + context.ReturnUrl.ShouldBe("/about"); + } + + [Fact] + public async Task Should_Handle_Same_Culture_Switch() + { + // Switching to the same culture — no change + var context = CreateContext("en", "en", "/en/about"); + await _replacement.ReplaceAsync(context); + context.ReturnUrl.ShouldBe("/en/about"); + } + + [Fact] + public async Task Should_Replace_Case_Insensitive() + { + var context = CreateContext("zh-hans", "en", "/zh-Hans/about"); + await _replacement.ReplaceAsync(context); + context.ReturnUrl.ShouldBe("/en/about"); + } + + [Fact] + public async Task Should_Handle_Whitespace_ReturnUrl() + { + var context = new QueryStringCultureReplacementContext( + new DefaultHttpContext(), new RequestCulture("en"), " "); + await _replacement.ReplaceAsync(context); + context.ReturnUrl.ShouldBe(" "); + } + + [Fact] + public async Task Should_Prefer_CurrentCulture_Over_RouteData() + { + var httpContext = new DefaultHttpContext(); + httpContext.Request.RouteValues["culture"] = "fr"; + var context = new QueryStringCultureReplacementContext( + httpContext, new RequestCulture("en"), "/tr/about", currentCulture: "tr"); + await _replacement.ReplaceAsync(context); + // Should use "tr" from CurrentCulture, not "fr" from RouteData + context.ReturnUrl.ShouldBe("/en/about"); + } + + [Fact] + public async Task Should_Only_Replace_Query_String_When_No_Route_Culture() + { + // No currentCulture, no RouteData — only query string replacement + var context = new QueryStringCultureReplacementContext( + new DefaultHttpContext(), new RequestCulture("en", "en"), "/?culture=tr&ui-culture=tr"); + await _replacement.ReplaceAsync(context); + context.ReturnUrl.ShouldBe("/?culture=en&ui-culture=en"); + } + + [Fact] + public async Task Should_Replace_Culture_When_Only_Culture_Param_Present() + { + // culture= and ui-culture= are now handled independently + var context = new QueryStringCultureReplacementContext( + new DefaultHttpContext(), new RequestCulture("en"), "/?culture=tr"); + await _replacement.ReplaceAsync(context); + context.ReturnUrl.ShouldBe("/?culture=en"); + } + + [Fact] + public async Task Should_Replace_UiCulture_When_Only_UiCulture_Param_Present() + { + var context = new QueryStringCultureReplacementContext( + new DefaultHttpContext(), new RequestCulture("en"), "/?ui-culture=tr"); + await _replacement.ReplaceAsync(context); + context.ReturnUrl.ShouldBe("/?ui-culture=en"); + } + + [Fact] + public async Task Should_Support_Numeric_Region_Culture_Tag() + { + // es-419 (Latin America Spanish) contains a digit — previously the regex + // [A-Za-z-]+ would not match it, leaving the query string unreplaced. + var context = new QueryStringCultureReplacementContext( + new DefaultHttpContext(), + new RequestCulture("es-419", "es-419"), + "/home?culture=tr&ui-culture=tr"); + await _replacement.ReplaceAsync(context); + context.ReturnUrl.ShouldBe("/home?culture=es-419&ui-culture=es-419"); + } + + [Fact] + public async Task Should_Replace_Only_First_Culture_Occurrence_In_Path() + { + // /en/products/en/details — the second "/en" is part of the path content, + // not a culture prefix, and must not be replaced. + var context = CreateContext("en", "tr", "/en/products/en/details"); + await _replacement.ReplaceAsync(context); + context.ReturnUrl.ShouldBe("/tr/products/en/details"); + } + + private static QueryStringCultureReplacementContext CreateContext( + string currentCulture, string targetCulture, string returnUrl) + { + return new QueryStringCultureReplacementContext( + new DefaultHttpContext(), + new RequestCulture(targetCulture), + returnUrl, + currentCulture); + } +} diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/RouteBasedCultureApiRouting_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/RouteBasedCultureApiRouting_Tests.cs new file mode 100644 index 00000000000..84f16e487ca --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/RouteBasedCultureApiRouting_Tests.cs @@ -0,0 +1,48 @@ +using System.Net; +using System.Threading.Tasks; +using Shouldly; +using Volo.Abp.AspNetCore.App; +using Xunit; + +namespace Volo.Abp.AspNetCore.Mvc.Localization; + +public class RouteBasedCultureApiRouting_Tests : AspNetCoreMvcTestBase +{ + [Fact] + public async Task Api_Route_Should_Not_Be_Intercepted_By_Culture_Route() + { + var response = await GetResponseAsync("api/json-result-test/json-result-action"); + response.StatusCode.ShouldBe(HttpStatusCode.OK); + response.Content.Headers.ContentType!.MediaType.ShouldBe("application/json"); + } + + [Fact] + public async Task Controller_Should_Work_With_Culture_Prefix() + { + var result = await GetResponseAsStringAsync( + "/tr" + GetUrl(nameof(SimpleController.Index))); + result.ShouldBe("Index-Result"); + } + + [Fact] + public async Task Controller_Should_Work_Without_Culture_Prefix() + { + var result = await GetResponseAsStringAsync( + GetUrl(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); + } +} diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/RouteBasedCultureNavigationHelper_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/RouteBasedCultureNavigationHelper_Tests.cs new file mode 100644 index 00000000000..08706b3179e --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/RouteBasedCultureNavigationHelper_Tests.cs @@ -0,0 +1,103 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components; +using Shouldly; +using Volo.Abp.AspNetCore.Components.WebAssembly; +using Volo.Abp.Localization; +using Xunit; + +namespace Volo.Abp.AspNetCore.Mvc.Localization; + +public class RouteBasedCultureNavigationHelper_Tests +{ + private static readonly IEnumerable 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; + } + } +} diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/RouteBasedCultureUrlHelper_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/RouteBasedCultureUrlHelper_Tests.cs new file mode 100644 index 00000000000..b2e8ca129ae --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/RouteBasedCultureUrlHelper_Tests.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Threading.Tasks; +using NSubstitute; +using Shouldly; +using Volo.Abp.AspNetCore.Components.WebAssembly; +using Volo.Abp.AspNetCore.Mvc.ApplicationConfigurations; +using Volo.Abp.AspNetCore.Mvc.Client; +using Volo.Abp.Localization; +using Xunit; + +namespace Volo.Abp.AspNetCore.Mvc.Localization; + +public class RouteBasedCultureUrlHelper_Tests +{ + private readonly ICachedApplicationConfigurationClient _configClient; + private readonly RouteBasedCultureUrlHelper _helper; + private readonly ApplicationConfigurationDto _config; + + public RouteBasedCultureUrlHelper_Tests() + { + _config = new ApplicationConfigurationDto + { + Localization = new ApplicationLocalizationConfigurationDto + { + UseRouteBasedCulture = true, + Languages = new List + { + new LanguageInfo("en"), + new LanguageInfo("zh-Hans"), + new LanguageInfo("tr"), + new LanguageInfo("es-419"), + } + } + }; + + _configClient = Substitute.For(); + _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(); + } +} diff --git a/framework/test/Volo.Abp.AspNetCore.Tests/Volo/Abp/AspNetCore/Localization/RouteBasedCultureTestModule.cs b/framework/test/Volo.Abp.AspNetCore.Tests/Volo/Abp/AspNetCore/Localization/RouteBasedCultureTestModule.cs new file mode 100644 index 00000000000..31d6dc2bb65 --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.Tests/Volo/Abp/AspNetCore/Localization/RouteBasedCultureTestModule.cs @@ -0,0 +1,53 @@ +using System.Globalization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.RequestLocalization; +using Microsoft.AspNetCore.Routing; +using Volo.Abp.Localization; +using Volo.Abp.Modularity; + +namespace Volo.Abp.AspNetCore.Localization; + +[DependsOn(typeof(AbpAspNetCoreTestModule))] +public class RouteBasedCultureTestModule : AbpModule +{ + public override void ConfigureServices(ServiceConfigurationContext context) + { + Configure(options => + { + options.UseRouteBasedCulture = true; + }); + + Configure(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); + }); + }); + } +} diff --git a/framework/test/Volo.Abp.AspNetCore.Tests/Volo/Abp/AspNetCore/Localization/RouteBasedCulture_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Tests/Volo/Abp/AspNetCore/Localization/RouteBasedCulture_Tests.cs new file mode 100644 index 00000000000..ac3be732757 --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.Tests/Volo/Abp/AspNetCore/Localization/RouteBasedCulture_Tests.cs @@ -0,0 +1,75 @@ +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Localization; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Shouldly; +using Xunit; + +namespace Volo.Abp.AspNetCore.Localization; + +public class RouteBasedCulture_Tests : IAsyncLifetime +{ + private WebApplication _app; + private HttpClient _client; + + public async Task InitializeAsync() + { + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseTestServer(); + builder.Host.UseAutofac(); + await builder.AddApplicationAsync(); + _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"); + } +} diff --git a/modules/account/src/Volo.Abp.Account.Blazor/AbpAccountBlazorUserMenuContributor.cs b/modules/account/src/Volo.Abp.Account.Blazor/AbpAccountBlazorUserMenuContributor.cs index 3fae85c5b55..d40170ffecb 100644 --- a/modules/account/src/Volo.Abp.Account.Blazor/AbpAccountBlazorUserMenuContributor.cs +++ b/modules/account/src/Volo.Abp.Account.Blazor/AbpAccountBlazorUserMenuContributor.cs @@ -15,7 +15,7 @@ public Task ConfigureMenuAsync(MenuConfigurationContext context) var accountResource = context.GetLocalizer(); - context.Menu.AddItem(new ApplicationMenuItem("Account.Manage", accountResource["MyAccount"], url: "account/manage-profile", icon: "fa fa-cog")); + context.Menu.AddItem(new ApplicationMenuItem("Account.Manage", accountResource["MyAccount"], url: "~/account/manage-profile", icon: "fa fa-cog")); return Task.CompletedTask; } diff --git a/modules/account/src/Volo.Abp.Account.Blazor/Pages/Account/AccountManage.razor b/modules/account/src/Volo.Abp.Account.Blazor/Pages/Account/AccountManage.razor index 2341b9dc5b3..c5ed90e5aae 100644 --- a/modules/account/src/Volo.Abp.Account.Blazor/Pages/Account/AccountManage.razor +++ b/modules/account/src/Volo.Abp.Account.Blazor/Pages/Account/AccountManage.razor @@ -1,4 +1,5 @@ @page "/account/manage-profile" +@page "/{culture}/account/manage-profile" @using Microsoft.AspNetCore.Components.Forms @using Volo.Abp.Account.Localization @using Volo.Abp.AspNetCore.Components.Web diff --git a/modules/account/src/Volo.Abp.Account.Blazor/Pages/Account/AccountManage.razor.cs b/modules/account/src/Volo.Abp.Account.Blazor/Pages/Account/AccountManage.razor.cs index 1aef6551c22..7cc4e6e3572 100644 --- a/modules/account/src/Volo.Abp.Account.Blazor/Pages/Account/AccountManage.razor.cs +++ b/modules/account/src/Volo.Abp.Account.Blazor/Pages/Account/AccountManage.razor.cs @@ -8,6 +8,9 @@ namespace Volo.Abp.Account.Blazor.Pages.Account; public partial class AccountManage { + [Parameter] + public string? Culture { get; set; } + [Inject] protected IProfileAppService ProfileAppService { get; set; } [Inject] protected IUiMessageService UiMessageService { get; set; } diff --git a/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.Server.BasicTheme/Themes/Basic/LoginDisplay.razor b/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.Server.BasicTheme/Themes/Basic/LoginDisplay.razor index 973912af309..fe01c0d5bb6 100644 --- a/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.Server.BasicTheme/Themes/Basic/LoginDisplay.razor +++ b/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.Server.BasicTheme/Themes/Basic/LoginDisplay.razor @@ -33,6 +33,6 @@ - @L["Login"] + @L["Login"] diff --git a/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.Server.BasicTheme/Themes/Basic/LoginDisplay.razor.cs b/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.Server.BasicTheme/Themes/Basic/LoginDisplay.razor.cs index 84696d7f29e..0ce5132a25d 100644 --- a/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.Server.BasicTheme/Themes/Basic/LoginDisplay.razor.cs +++ b/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.Server.BasicTheme/Themes/Basic/LoginDisplay.razor.cs @@ -2,6 +2,8 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Routing; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.RequestLocalization; using Volo.Abp.UI.Navigation; namespace Volo.Abp.AspNetCore.Components.Server.BasicTheme.Themes.Basic; @@ -11,6 +13,9 @@ public partial class LoginDisplay : IDisposable [Inject] protected IMenuManager MenuManager { get; set; } + [Inject] + protected IHttpContextAccessor HttpContextAccessor { get; set; } + protected ApplicationMenu Menu { get; set; } protected override async Task OnInitializedAsync() @@ -20,6 +25,12 @@ protected override async Task OnInitializedAsync() Navigation.LocationChanged += OnLocationChanged; } + protected string GetLoginUrl() + { + var culture = AbpRequestCultureCookieHelper.GetRouteCulture(HttpContextAccessor.HttpContext); + return string.IsNullOrEmpty(culture) ? "Account/Login" : $"{culture}/Account/Login"; + } + protected virtual void OnLocationChanged(object sender, LocationChangedEventArgs e) { InvokeAsync(StateHasChanged); diff --git a/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/BasicThemeToolbarContributor.cs b/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/BasicThemeToolbarContributor.cs index 39cc08ddd19..8888d36fb17 100644 --- a/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/BasicThemeToolbarContributor.cs +++ b/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/BasicThemeToolbarContributor.cs @@ -14,8 +14,6 @@ public Task ConfigureToolbarAsync(IToolbarConfigurationContext context) { if (context.Toolbar.Name == StandardToolbars.Main) { - context.Toolbar.Items.Add(new ToolbarItem(typeof(LanguageSwitch))); - //TODO: Can we find a different way to understand if authentication was configured or not? var authenticationStateProvider = context.ServiceProvider .GetService(); @@ -24,6 +22,8 @@ public Task ConfigureToolbarAsync(IToolbarConfigurationContext context) { context.Toolbar.Items.Add(new ToolbarItem(typeof(LoginDisplay))); } + + context.Toolbar.Items.Add(new ToolbarItem(typeof(LanguageSwitch))); } return Task.CompletedTask; diff --git a/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/Themes/Basic/LanguageSwitch.razor b/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/Themes/Basic/LanguageSwitch.razor index 7814000136d..86a74a59290 100644 --- a/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/Themes/Basic/LanguageSwitch.razor +++ b/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/Themes/Basic/LanguageSwitch.razor @@ -1,10 +1,15 @@ -@using Volo.Abp.Localization +@using Volo.Abp.Localization @using System.Globalization @using System.Collections.Immutable @using Volo.Abp.AspNetCore.Components.Web +@using Volo.Abp.AspNetCore.Components.WebAssembly +@using Volo.Abp.AspNetCore.Mvc.Client @inject ILanguageProvider LanguageProvider @inject IJSRuntime JsRuntime @inject ICookieService CookieService +@inject NavigationManager NavigationManager +@inject ICachedApplicationConfigurationClient ConfigurationClient +@inject IRouteBasedCultureNavigationHelper CultureNavigationHelper @if (_otherLanguages != null && _otherLanguages.Any()) { @@ -22,9 +27,13 @@ @code { private IReadOnlyList _otherLanguages; private LanguageInfo _currentLanguage; + private bool _useRouteBasedCulture; protected override async Task OnInitializedAsync() { + var config = await ConfigurationClient.GetAsync(); + _useRouteBasedCulture = config.Localization.UseRouteBasedCulture; + var selectedLanguageName = await JsRuntime.InvokeAsync( "localStorage.getItem", "Abp.SelectedLanguage" @@ -57,6 +66,13 @@ private async Task ChangeLanguageAsync(LanguageInfo language) { + if (_useRouteBasedCulture) + { + await CultureNavigationHelper.NavigateToNewCultureAsync( + NavigationManager, language, _otherLanguages.Append(_currentLanguage)); + return; + } + await JsRuntime.InvokeVoidAsync( "localStorage.setItem", "Abp.SelectedLanguage", diff --git a/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/Themes/Basic/LoginDisplay.razor b/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/Themes/Basic/LoginDisplay.razor index 6c02e4ffc0d..b961dc6e6bc 100644 --- a/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/Themes/Basic/LoginDisplay.razor +++ b/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/Themes/Basic/LoginDisplay.razor @@ -35,6 +35,6 @@ - @UiLocalizer["Login"] + @UiLocalizer["Login"] diff --git a/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/Themes/Basic/LoginDisplay.razor.cs b/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/Themes/Basic/LoginDisplay.razor.cs index 08d40e4bfe8..7fe86a6ecc6 100644 --- a/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/Themes/Basic/LoginDisplay.razor.cs +++ b/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/Themes/Basic/LoginDisplay.razor.cs @@ -1,10 +1,11 @@ -using System; +using System; using System.Threading.Tasks; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Routing; using Microsoft.AspNetCore.Components.WebAssembly.Authentication; using Microsoft.JSInterop; using Volo.Abp.AspNetCore.Components.Web.Security; +using Volo.Abp.AspNetCore.Components.WebAssembly; using Volo.Abp.UI.Navigation; namespace Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme.Themes.Basic; @@ -17,10 +18,20 @@ public partial class LoginDisplay : IDisposable [Inject] protected ApplicationConfigurationChangedService ApplicationConfigurationChangedService { get; set; } + [Inject] + protected IRouteBasedCultureUrlHelper CultureUrlHelper { get; set; } + protected ApplicationMenu Menu { get; set; } + protected string LoginUrl { get; set; } = string.Empty; + + protected string LogoutUrl { get; set; } = string.Empty; + protected async override Task OnInitializedAsync() { + LoginUrl = await CultureUrlHelper.PrependCulturePrefixAsync(AuthenticationOptions.Value.LoginUrl); + LogoutUrl = await CultureUrlHelper.PrependCulturePrefixAsync(AuthenticationOptions.Value.LogoutUrl); + Menu = await MenuManager.GetAsync(StandardMenus.User); Navigation.LocationChanged += OnLocationChanged; @@ -35,6 +46,8 @@ protected virtual void OnLocationChanged(object sender, LocationChangedEventArgs private async void ApplicationConfigurationChanged() { + LoginUrl = await CultureUrlHelper.PrependCulturePrefixAsync(AuthenticationOptions.Value.LoginUrl); + LogoutUrl = await CultureUrlHelper.PrependCulturePrefixAsync(AuthenticationOptions.Value.LogoutUrl); Menu = await MenuManager.GetAsync(StandardMenus.User); await InvokeAsync(StateHasChanged); } @@ -47,9 +60,11 @@ public void Dispose() private async Task NavigateToAsync(string uri, string target = null) { + uri = uri?.TrimStart('~', '/') ?? uri; + if (target == "_blank") { - await JsRuntime.InvokeVoidAsync("open", uri, target); + await JsRuntime.InvokeVoidAsync("open", Navigation.ToAbsoluteUri(uri).ToString(), target); } else { @@ -57,15 +72,15 @@ private async Task NavigateToAsync(string uri, string target = null) } } - private void BeginSignOut() + private async Task BeginSignOut() { if (AbpAspNetCoreComponentsWebOptions.Value.IsBlazorWebApp) { - Navigation.NavigateTo(AuthenticationOptions.Value.LogoutUrl, forceLoad: true); + Navigation.NavigateTo(LogoutUrl, forceLoad: true); } else { - Navigation.NavigateToLogout(AuthenticationOptions.Value.LogoutUrl); + Navigation.NavigateToLogout(LogoutUrl); } } } diff --git a/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/Themes/Basic/WebAssemblyRedirectToLogin.razor b/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/Themes/Basic/WebAssemblyRedirectToLogin.razor index f4f3e152ce6..246682edf8a 100644 --- a/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/Themes/Basic/WebAssemblyRedirectToLogin.razor +++ b/modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/Themes/Basic/WebAssemblyRedirectToLogin.razor @@ -1,25 +1,22 @@ -@inject NavigationManager Navigation @using Volo.Abp.DependencyInjection @using Volo.Abp.AspNetCore.Components.Web.BasicTheme.Themes.Basic -@using Microsoft.AspNetCore.Components.WebAssembly.Authentication +@using Volo.Abp.AspNetCore.Components.WebAssembly.Theming @using Microsoft.Extensions.Options +@using Volo.Abp.AspNetCore.Components.WebAssembly @using Volo.Abp.AspNetCore.Components.Web @inherits RedirectToLogin @attribute [ExposeServices(typeof(RedirectToLogin))] @attribute [Dependency(ReplaceServices = true)] -@inject IOptions AuthenticationOptions +@inject NavigationManager Navigation +@inject IOptions AuthOptions +@inject IRouteBasedCultureUrlHelper CultureUrlHelper @inject IOptions 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); } } diff --git a/modules/identity/src/Volo.Abp.Identity.Blazor/Pages/Identity/RoleManagement.razor b/modules/identity/src/Volo.Abp.Identity.Blazor/Pages/Identity/RoleManagement.razor index 2dcaea5eac2..2db9953cefc 100644 --- a/modules/identity/src/Volo.Abp.Identity.Blazor/Pages/Identity/RoleManagement.razor +++ b/modules/identity/src/Volo.Abp.Identity.Blazor/Pages/Identity/RoleManagement.razor @@ -1,4 +1,5 @@ @page "/identity/roles" +@page "/{culture}/identity/roles" @attribute [Authorize(IdentityPermissions.Roles.Default)] @using Volo.Abp.Identity @using Microsoft.AspNetCore.Authorization diff --git a/modules/identity/src/Volo.Abp.Identity.Blazor/Pages/Identity/RoleManagement.razor.cs b/modules/identity/src/Volo.Abp.Identity.Blazor/Pages/Identity/RoleManagement.razor.cs index 4e9a6576d1d..485f2442d01 100644 --- a/modules/identity/src/Volo.Abp.Identity.Blazor/Pages/Identity/RoleManagement.razor.cs +++ b/modules/identity/src/Volo.Abp.Identity.Blazor/Pages/Identity/RoleManagement.razor.cs @@ -1,6 +1,7 @@ using System.Threading.Tasks; using Blazorise; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Components; using System; using System.Collections.Generic; using System.Threading.Tasks; @@ -16,6 +17,9 @@ namespace Volo.Abp.Identity.Blazor.Pages.Identity; public partial class RoleManagement { + [Parameter] + public string? Culture { get; set; } + protected const string PermissionProviderName = "R"; protected PermissionManagementModal PermissionManagementModal; diff --git a/modules/identity/src/Volo.Abp.Identity.Blazor/Pages/Identity/UserManagement.razor b/modules/identity/src/Volo.Abp.Identity.Blazor/Pages/Identity/UserManagement.razor index 28cb7f0bedc..c54970bd331 100644 --- a/modules/identity/src/Volo.Abp.Identity.Blazor/Pages/Identity/UserManagement.razor +++ b/modules/identity/src/Volo.Abp.Identity.Blazor/Pages/Identity/UserManagement.razor @@ -1,4 +1,5 @@ @page "/identity/users" +@page "/{culture}/identity/users" @attribute [Authorize(IdentityPermissions.Users.Default)] @using Microsoft.AspNetCore.Authorization @using Volo.Abp.PermissionManagement.Blazor.Components diff --git a/modules/identity/src/Volo.Abp.Identity.Blazor/Pages/Identity/UserManagement.razor.cs b/modules/identity/src/Volo.Abp.Identity.Blazor/Pages/Identity/UserManagement.razor.cs index fc35245801c..ccce1cc363f 100644 --- a/modules/identity/src/Volo.Abp.Identity.Blazor/Pages/Identity/UserManagement.razor.cs +++ b/modules/identity/src/Volo.Abp.Identity.Blazor/Pages/Identity/UserManagement.razor.cs @@ -18,6 +18,9 @@ namespace Volo.Abp.Identity.Blazor.Pages.Identity; public partial class UserManagement { + [Parameter] + public string? Culture { get; set; } + protected const string PermissionProviderName = "U"; protected const string DefaultSelectedTab = "UserInformations"; diff --git a/modules/setting-management/src/Volo.Abp.SettingManagement.Blazor/Pages/SettingManagement/SettingManagement.razor b/modules/setting-management/src/Volo.Abp.SettingManagement.Blazor/Pages/SettingManagement/SettingManagement.razor index ad571fb4488..74d1a50b7d2 100644 --- a/modules/setting-management/src/Volo.Abp.SettingManagement.Blazor/Pages/SettingManagement/SettingManagement.razor +++ b/modules/setting-management/src/Volo.Abp.SettingManagement.Blazor/Pages/SettingManagement/SettingManagement.razor @@ -1,4 +1,5 @@ @page "/setting-management" +@page "/{culture}/setting-management" @using Microsoft.AspNetCore.Authorization @using Volo.Abp.AspNetCore.Components.Web.Theming.Layout @using Volo.Abp.Features diff --git a/modules/setting-management/src/Volo.Abp.SettingManagement.Blazor/Pages/SettingManagement/SettingManagement.razor.cs b/modules/setting-management/src/Volo.Abp.SettingManagement.Blazor/Pages/SettingManagement/SettingManagement.razor.cs index 1b443fc8272..3a1697ca862 100644 --- a/modules/setting-management/src/Volo.Abp.SettingManagement.Blazor/Pages/SettingManagement/SettingManagement.razor.cs +++ b/modules/setting-management/src/Volo.Abp.SettingManagement.Blazor/Pages/SettingManagement/SettingManagement.razor.cs @@ -12,6 +12,9 @@ namespace Volo.Abp.SettingManagement.Blazor.Pages.SettingManagement; public partial class SettingManagement { + [Parameter] + public string? Culture { get; set; } + [Inject] protected IServiceProvider ServiceProvider { get; set; } diff --git a/modules/tenant-management/src/Volo.Abp.TenantManagement.Blazor/Pages/TenantManagement/TenantManagement.razor b/modules/tenant-management/src/Volo.Abp.TenantManagement.Blazor/Pages/TenantManagement/TenantManagement.razor index b7313b0c9a4..49e4c648220 100644 --- a/modules/tenant-management/src/Volo.Abp.TenantManagement.Blazor/Pages/TenantManagement/TenantManagement.razor +++ b/modules/tenant-management/src/Volo.Abp.TenantManagement.Blazor/Pages/TenantManagement/TenantManagement.razor @@ -1,4 +1,5 @@ @page "/tenant-management/tenants" +@page "/{culture}/tenant-management/tenants" @attribute [Authorize(TenantManagementPermissions.Tenants.Default)] @using Microsoft.AspNetCore.Authorization @using Volo.Abp.FeatureManagement.Blazor.Components diff --git a/modules/tenant-management/src/Volo.Abp.TenantManagement.Blazor/Pages/TenantManagement/TenantManagement.razor.cs b/modules/tenant-management/src/Volo.Abp.TenantManagement.Blazor/Pages/TenantManagement/TenantManagement.razor.cs index 272bd9cf7a7..c1fdbce867f 100644 --- a/modules/tenant-management/src/Volo.Abp.TenantManagement.Blazor/Pages/TenantManagement/TenantManagement.razor.cs +++ b/modules/tenant-management/src/Volo.Abp.TenantManagement.Blazor/Pages/TenantManagement/TenantManagement.razor.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using Blazorise; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Components; using Volo.Abp.AspNetCore.Components.Web.Extensibility.EntityActions; using Volo.Abp.AspNetCore.Components.Web.Extensibility.TableColumns; using Volo.Abp.AspNetCore.Components.Web.Theming.PageToolbars; @@ -14,6 +15,9 @@ namespace Volo.Abp.TenantManagement.Blazor.Pages.TenantManagement; public partial class TenantManagement { + [Parameter] + public string? Culture { get; set; } + protected const string FeatureProviderName = "T"; protected bool HasManageFeaturesPermission; diff --git a/npm/ng-packs/packages/core/src/lib/components/dynamic-layout.component.ts b/npm/ng-packs/packages/core/src/lib/components/dynamic-layout.component.ts index 81f9305f68c..8d6b5f3992c 100644 --- a/npm/ng-packs/packages/core/src/lib/components/dynamic-layout.component.ts +++ b/npm/ng-packs/packages/core/src/lib/components/dynamic-layout.component.ts @@ -14,7 +14,8 @@ import { ReplaceableComponentsService } from '../services/replaceable-components import { RouterEvents } from '../services/router-events.service'; import { RoutesService } from '../services/routes.service'; import { SubscriptionService } from '../services/subscription.service'; -import { findRoute, getRoutePath } from '../utils/route-utils'; +import { RouteBasedCultureUrlService } from '../services/route-based-culture-url.service'; +import { findRoute } from '../utils/route-utils'; import { TreeNode } from '../utils/tree-utils'; import { DYNAMIC_LAYOUTS_TOKEN } from '../tokens/dynamic-layout.token'; import { EnvironmentService } from '../services'; @@ -46,6 +47,7 @@ export class DynamicLayoutComponent { protected readonly subscription = inject(SubscriptionService); protected readonly routerEvents = inject(RouterEvents); protected readonly environment = inject(EnvironmentService); + protected readonly routeCultureUrl = inject(RouteBasedCultureUrlService); constructor() { const dynamicLayoutComponent = inject(DynamicLayoutComponent, { optional: true, skipSelf: true }); @@ -85,7 +87,7 @@ export class DynamicLayoutComponent { const routeData = this.route.snapshot.data || {}; let expectedLayout = routeData['layout'] as eLayoutType; - let node = findRoute(this.routes, getRoutePath(this.router)); + let node = findRoute(this.routes, this.routeCultureUrl.getRoutePathForMatching(this.router)); node = { parent: node } as TreeNode; while (node.parent) { diff --git a/npm/ng-packs/packages/core/src/lib/guards/permission.guard.ts b/npm/ng-packs/packages/core/src/lib/guards/permission.guard.ts index 41e07f8d1bc..a6ae0405973 100644 --- a/npm/ng-packs/packages/core/src/lib/guards/permission.guard.ts +++ b/npm/ng-packs/packages/core/src/lib/guards/permission.guard.ts @@ -10,8 +10,14 @@ import { HttpErrorResponse } from '@angular/common/http'; import { Observable, of } from 'rxjs'; import { filter, map, switchMap, take } from 'rxjs/operators'; import { AuthService, IAbpGuard } from '../abstracts'; -import { findRoute, getRoutePath } from '../utils/route-utils'; -import { RoutesService, PermissionService, HttpErrorReporterService, ConfigStateService } from '../services'; +import { findRoute } from '../utils/route-utils'; +import { + RoutesService, + PermissionService, + HttpErrorReporterService, + ConfigStateService, + RouteBasedCultureUrlService, +} from '../services'; import { isPlatformServer } from '@angular/common'; /** * @deprecated Use `permissionGuard` *function* instead. @@ -26,12 +32,16 @@ export class PermissionGuard implements IAbpGuard { protected readonly permissionService = inject(PermissionService); protected readonly httpErrorReporter = inject(HttpErrorReporterService); protected readonly configStateService = inject(ConfigStateService); + protected readonly routeCultureUrl = inject(RouteBasedCultureUrlService); canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot): Observable { let { requiredPolicy } = route.data || {}; if (!requiredPolicy) { - const routeFound = findRoute(this.routesService, getRoutePath(this.router, state.url)); + const routeFound = findRoute( + this.routesService, + this.routeCultureUrl.getRoutePathForMatching(this.router, state.url), + ); requiredPolicy = routeFound?.requiredPolicy; } @@ -70,12 +80,13 @@ export const permissionGuard: CanActivateFn = ( const permissionService = inject(PermissionService); const httpErrorReporter = inject(HttpErrorReporterService); const configStateService = inject(ConfigStateService); + const routeCultureUrl = inject(RouteBasedCultureUrlService); const platformId = inject(PLATFORM_ID); let { requiredPolicy } = route.data || {}; if (!requiredPolicy) { - const routeFound = findRoute(routesService, getRoutePath(router, state.url)); + const routeFound = findRoute(routesService, routeCultureUrl.getRoutePathForMatching(router, state.url)); requiredPolicy = routeFound?.requiredPolicy; } diff --git a/npm/ng-packs/packages/core/src/lib/pipes/index.ts b/npm/ng-packs/packages/core/src/lib/pipes/index.ts index 0320350f55e..0dfe63d39f8 100644 --- a/npm/ng-packs/packages/core/src/lib/pipes/index.ts +++ b/npm/ng-packs/packages/core/src/lib/pipes/index.ts @@ -8,4 +8,5 @@ export * from './short-date-time.pipe'; export * from './utc-to-local.pipe'; export * from './async-localization.pipe'; export * from './lazy-localization.pipe'; +export * from './route-culture-url.pipe'; export * from './html-encode.pipe'; diff --git a/npm/ng-packs/packages/core/src/lib/pipes/route-culture-url.pipe.ts b/npm/ng-packs/packages/core/src/lib/pipes/route-culture-url.pipe.ts new file mode 100644 index 00000000000..26e43153e17 --- /dev/null +++ b/npm/ng-packs/packages/core/src/lib/pipes/route-culture-url.pipe.ts @@ -0,0 +1,18 @@ +import { Pipe, PipeTransform, inject } from '@angular/core'; +import { RouteBasedCultureUrlService } from '../services/route-based-culture-url.service'; + +/** + * Prefixes menu/navigation paths with the current culture when route-based culture is enabled. + * Impure so links update after language changes. + */ +@Pipe({ + name: 'abpRouteCultureUrl', + pure: false, +}) +export class AbpRouteCultureUrlPipe implements PipeTransform { + private readonly url = inject(RouteBasedCultureUrlService); + + transform(path: string | undefined | null): string | undefined | null { + return this.url.prefixPathWithCulture(path); + } +} diff --git a/npm/ng-packs/packages/core/src/lib/proxy/volo/abp/asp-net-core/mvc/application-configurations/models.ts b/npm/ng-packs/packages/core/src/lib/proxy/volo/abp/asp-net-core/mvc/application-configurations/models.ts index 524c9a8c78d..b2d7b67bea4 100644 --- a/npm/ng-packs/packages/core/src/lib/proxy/volo/abp/asp-net-core/mvc/application-configurations/models.ts +++ b/npm/ng-packs/packages/core/src/lib/proxy/volo/abp/asp-net-core/mvc/application-configurations/models.ts @@ -42,6 +42,7 @@ export interface ApplicationLocalizationConfigurationDto { defaultResourceName?: string; languagesMap: Record; languageFilesMap: Record; + useRouteBasedCulture: boolean; } export interface ApplicationLocalizationDto { diff --git a/npm/ng-packs/packages/core/src/lib/services/index.ts b/npm/ng-packs/packages/core/src/lib/services/index.ts index 205fd2f0dc0..cccac2ffb6a 100644 --- a/npm/ng-packs/packages/core/src/lib/services/index.ts +++ b/npm/ng-packs/packages/core/src/lib/services/index.ts @@ -16,6 +16,8 @@ export * from './resource-wait.service'; export * from './rest.service'; export * from './router-events.service'; export * from './router-wait.service'; +export * from './route-based-culture.service'; +export * from './route-based-culture-url.service'; export * from './routes.service'; export * from './session-state.service'; export * from './subscription.service'; diff --git a/npm/ng-packs/packages/core/src/lib/services/route-based-culture-url.service.ts b/npm/ng-packs/packages/core/src/lib/services/route-based-culture-url.service.ts new file mode 100644 index 00000000000..cbd94f38032 --- /dev/null +++ b/npm/ng-packs/packages/core/src/lib/services/route-based-culture-url.service.ts @@ -0,0 +1,170 @@ +import { Location } from '@angular/common'; +import { isPlatformBrowser } from '@angular/common'; +import { Injectable, PLATFORM_ID, inject } from '@angular/core'; +import { Router } from '@angular/router'; +import { LanguageInfo } from '../proxy/volo/abp/localization/models'; +import { + findMatchingCultureName, + getFirstPathSegment, + stripCultureSegmentFromPath, +} from '../utils/route-based-culture.utils'; +import { getRoutePath } from '../utils/route-utils'; +import { ConfigStateService } from './config-state.service'; +import { SessionStateService } from './session-state.service'; + +/** + * URL helpers for route-based culture: prefix menu links, strip culture for route matching, + * and navigate when the user picks a language so the URL stays in sync with the session. + */ +@Injectable({ + providedIn: 'root', +}) +export class RouteBasedCultureUrlService { + private readonly router = inject(Router); + private readonly location = inject(Location); + private readonly configState = inject(ConfigStateService); + private readonly sessionState = inject(SessionStateService); + private readonly platformId = inject(PLATFORM_ID); + + /** Cached from localization config; refreshed when application configuration updates. */ + private useRouteBasedCulture = false; + private languages: LanguageInfo[] | undefined; + + constructor() { + this.refreshRouteCultureCache(); + this.configState.getAll$().subscribe(() => this.refreshRouteCultureCache()); + } + + private refreshRouteCultureCache(): void { + const loc = this.configState.getOne('localization'); + if (!loc) { + this.useRouteBasedCulture = false; + this.languages = undefined; + return; + } + this.useRouteBasedCulture = !!loc.useRouteBasedCulture; + this.languages = loc.languages as LanguageInfo[] | undefined; + } + + private isRouteBasedCultureEnabled(): boolean { + return this.useRouteBasedCulture; + } + + /** + * Same as {@link getRoutePath} but removes the leading culture segment when route-based culture is enabled, + * so paths match `RoutesService` entries (e.g. `/identity/users`). + */ + getRoutePathForMatching(router: Router, url = router.url): string { + const raw = getRoutePath(router, url); + return this.stripCulturePrefixIfEnabled(raw); + } + + /** + * Strips the leading culture segment when it matches a configured language and route-based culture is enabled. + * Use for menu active state, breadcrumbs, and any comparison between `router.url` and `RoutesService` paths. + */ + stripCulturePrefixIfEnabled(path: string): string { + if (!this.isRouteBasedCultureEnabled() || !path) { + return path; + } + return this.stripCulturePrefix(path); + } + + /** + * Alias for {@link stripCulturePrefixIfEnabled}: normalizes the current URL for **menu highlighting** + * when menu `link` values omit the culture segment (e.g. Lepton `NavbarRoutesComponent`, `NavbarService.getRouteItem`). + */ + normalizeForMenuMatch(path: string): string { + return this.stripCulturePrefixIfEnabled(path); + } + + /** + * Removes the first segment when it is a known UI culture (for matching and normalization). + */ + stripCulturePrefix(path: string): string { + return stripCultureSegmentFromPath(path, this.languages); + } + + /** + * Prefixes an app path with the current session culture when route-based culture is enabled + * (e.g. `/identity/users` → `/en/identity/users`). Use for `routerLink`, `navigateByUrl`, etc. + */ + prefixPathWithCulture(path: string | undefined | null): string | undefined | null { + if (path == null || path === '') { + return path; + } + + if (!this.isRouteBasedCultureEnabled()) { + return path; + } + + if (/^https?:\/\//i.test(path)) { + return path; + } + + const lang = this.sessionState.getLanguage(); + if (!lang) { + return path; + } + + const stripped = this.stripCulturePrefix(path); + const normalized = stripped.startsWith('/') ? stripped : '/' + stripped; + const suffix = normalized === '/' ? '' : normalized; + return `/${lang}${suffix}`; + } + + /** + * Rewrites the current URL so the first segment is {@link cultureName} (or prepends it). + * Call this when the user selects a language in the UI instead of only {@link SessionStateService.setLanguage} + * so the address bar stays aligned with the session. + */ + navigateToUrlWithCulture(cultureName: string): Promise | 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 | 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}`; + } +} diff --git a/npm/ng-packs/packages/core/src/lib/services/route-based-culture.service.ts b/npm/ng-packs/packages/core/src/lib/services/route-based-culture.service.ts new file mode 100644 index 00000000000..f78b6130a9e --- /dev/null +++ b/npm/ng-packs/packages/core/src/lib/services/route-based-culture.service.ts @@ -0,0 +1,59 @@ +import { isPlatformBrowser } from '@angular/common'; +import { Injectable, PLATFORM_ID, inject } from '@angular/core'; +import { NavigationEnd, Router } from '@angular/router'; +import { filter } from 'rxjs/operators'; +import { LanguageInfo } from '../proxy/volo/abp/localization/models'; +import { findMatchingCultureName, getFirstPathSegment } from '../utils/route-based-culture.utils'; +import { ConfigStateService } from './config-state.service'; +import { SessionStateService } from './session-state.service'; + +/** + * When the backend enables URL-based localization (`localization.useRouteBasedCulture` from application configuration), + * keeps session language in sync with the first URL path segment (e.g. /en/..., /tr-TR/...). + * Works with nested routes because only the leading segment is interpreted as culture. + */ +@Injectable({ + providedIn: 'root', +}) +export class RouteBasedCultureService { + private readonly router = inject(Router); + private readonly configState = inject(ConfigStateService); + private readonly sessionState = inject(SessionStateService); + private readonly platformId = inject(PLATFORM_ID); + protected readonly localization = this.configState.getOne('localization'); + + constructor() { + if (!isPlatformBrowser(this.platformId)) { + return; + } + + if (!this.localization?.useRouteBasedCulture) { + return; + } + + this.router.events + .pipe(filter((e): e is NavigationEnd => e instanceof NavigationEnd)) + .subscribe(() => this.syncLanguageFromUrl()); + } + + /** + * Reads the culture from the current URL and updates session language when it matches a configured language. + * @param pathOverride Optional path (e.g. from `Location.path()` during app bootstrap before navigation settles). + */ + syncLanguageFromUrl(pathOverride?: string): void { + if (!isPlatformBrowser(this.platformId)) { + return; + } + + const languages = this.localization?.languages as LanguageInfo[] | undefined; + const path = pathOverride ?? this.router.url; + const firstSegment = getFirstPathSegment(path); + const cultureName = findMatchingCultureName(firstSegment, languages); + + if (!cultureName) { + return; + } + + this.sessionState.setLanguage(cultureName); + } +} diff --git a/npm/ng-packs/packages/core/src/lib/services/title-strategy.service.ts b/npm/ng-packs/packages/core/src/lib/services/title-strategy.service.ts index af961794f87..18d83290b1c 100644 --- a/npm/ng-packs/packages/core/src/lib/services/title-strategy.service.ts +++ b/npm/ng-packs/packages/core/src/lib/services/title-strategy.service.ts @@ -27,6 +27,10 @@ export class AbpTitleStrategy extends TitleStrategy { override updateTitle(routerState: RouterStateSnapshot) { this.routerState = routerState; + if (!routerState?.root) { + return; + } + const title = this.buildTitle(routerState); const projectName = this.localizationService.instant({ diff --git a/npm/ng-packs/packages/core/src/lib/tests/route-based-culture-url.service.spec.ts b/npm/ng-packs/packages/core/src/lib/tests/route-based-culture-url.service.spec.ts new file mode 100644 index 00000000000..eaaf94df1b7 --- /dev/null +++ b/npm/ng-packs/packages/core/src/lib/tests/route-based-culture-url.service.spec.ts @@ -0,0 +1,81 @@ +import { Location } from '@angular/common'; +import { TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { of } from 'rxjs'; +import { ConfigStateService } from '../services/config-state.service'; +import { RouteBasedCultureUrlService } from '../services/route-based-culture-url.service'; +import { SessionStateService } from '../services/session-state.service'; + +const languages = [ + { cultureName: 'en', displayName: 'English' }, + { cultureName: 'tr', displayName: 'Turkish' }, +]; + +describe('RouteBasedCultureUrlService', () => { + let service: RouteBasedCultureUrlService; + let configState: { getOne: ReturnType }; + let sessionState: { + getLanguage: ReturnType; + setLanguage: ReturnType; + }; + + 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'); + }); +}); diff --git a/npm/ng-packs/packages/core/src/lib/tests/route-based-culture.utils.spec.ts b/npm/ng-packs/packages/core/src/lib/tests/route-based-culture.utils.spec.ts new file mode 100644 index 00000000000..e9e2ea9b963 --- /dev/null +++ b/npm/ng-packs/packages/core/src/lib/tests/route-based-culture.utils.spec.ts @@ -0,0 +1,81 @@ +import { + findMatchingCultureName, + getFirstPathSegment, + normalizeUrlForRouteCultureMatch, + stripCultureSegmentFromPath, +} from '../utils/route-based-culture.utils'; +import type { LanguageInfo } from '../proxy/volo/abp/localization/models'; + +describe('route-based-culture.utils', () => { + describe('#getFirstPathSegment', () => { + test.each` + urlPath | expected + ${''} | ${''} + ${'/'} | ${''} + ${'/en'} | ${'en'} + ${'/en/account'} | ${'en'} + ${'en/account'} | ${'en'} + ${'/tr-TR/foo/bar'} | ${'tr-TR'} + ${'/zh-Hans/account?x=1'} | ${'zh-Hans'} + ${'tr/home#frag'} | ${'tr'} + `('should return $expected when urlPath is $urlPath', ({ urlPath, expected }) => { + expect(getFirstPathSegment(urlPath)).toBe(expected); + }); + }); + + describe('#findMatchingCultureName', () => { + const languages: LanguageInfo[] = [ + { cultureName: 'en' }, + { cultureName: 'tr-TR' }, + { cultureName: 'zh-Hans' }, + ]; + + test.each` + segment | expected + ${'en'} | ${'en'} + ${'EN'} | ${'en'} + ${'tr-tr'} | ${'tr-TR'} + ${'zh-hans'} | ${'zh-Hans'} + ${'account'} | ${undefined} + ${''} | ${undefined} + `('should return $expected when segment is $segment', ({ segment, expected }) => { + expect(findMatchingCultureName(segment, languages)).toBe(expected); + }); + + test('should return undefined when languages is empty', () => { + expect(findMatchingCultureName('en', [])).toBeUndefined(); + }); + }); + + describe('#stripCultureSegmentFromPath', () => { + const languages: LanguageInfo[] = [{ cultureName: 'en' }, { cultureName: 'tr' }]; + + test('should strip leading culture segment for menu matching', () => { + expect(stripCultureSegmentFromPath('/en/identity/users', languages)).toBe('/identity/users'); + }); + + test('should leave path unchanged when first segment is not a culture', () => { + expect(stripCultureSegmentFromPath('/identity/users', languages)).toBe('/identity/users'); + }); + + test('should preserve query string', () => { + expect(stripCultureSegmentFromPath('/en/home?x=1', languages)).toBe('/home?x=1'); + }); + }); + + describe('#normalizeUrlForRouteCultureMatch', () => { + const languages: LanguageInfo[] = [{ cultureName: 'en' }]; + + test('should no-op when useRouteBasedCulture is false', () => { + expect(normalizeUrlForRouteCultureMatch('/en/identity/users', false, languages)).toBe( + '/en/identity/users', + ); + }); + + test('should strip when enabled', () => { + expect(normalizeUrlForRouteCultureMatch('/en/identity/users', true, languages)).toBe( + '/identity/users', + ); + }); + }); +}); diff --git a/npm/ng-packs/packages/core/src/lib/tests/route-culture-url-matcher.spec.ts b/npm/ng-packs/packages/core/src/lib/tests/route-culture-url-matcher.spec.ts new file mode 100644 index 00000000000..bad77e0aac1 --- /dev/null +++ b/npm/ng-packs/packages/core/src/lib/tests/route-culture-url-matcher.spec.ts @@ -0,0 +1,44 @@ +import { UrlSegment } from '@angular/router'; +import { + createRouteCultureUrlMatcher, + isLikelyCultureSegment, +} from '../utils/route-culture-url-matcher'; + +describe('route-culture-url-matcher', () => { + describe('#isLikelyCultureSegment', () => { + test.each` + segment | expected + ${''} | ${false} + ${'en'} | ${true} + ${'tr'} | ${true} + ${'zh-Hans'} | ${true} + ${'pt-BR'} | ${true} + ${'home'} | ${false} + ${'identity'} | ${false} + ${'cms-kit'} | ${false} + ${'account'} | ${false} + `('should return $expected when segment is $segment', ({ segment, expected }) => { + expect(isLikelyCultureSegment(segment)).toBe(expected); + }); + }); + + describe('#createRouteCultureUrlMatcher', () => { + const matcher = createRouteCultureUrlMatcher(); + + test('should consume first segment when it is a culture code', () => { + const segments = [new UrlSegment('en', {}), new UrlSegment('home', {})]; + const result = matcher(segments, null as any, {} as any); + expect(result?.consumed).toEqual([segments[0]]); + expect(result?.posParams?.culture).toBe(segments[0]); + }); + + test('should return null when first segment is not a culture code', () => { + const segments = [new UrlSegment('identity', {})]; + expect(matcher(segments, null as any, {} as any)).toBeNull(); + }); + + test('should return null when there are no segments', () => { + expect(matcher([], null as any, {} as any)).toBeNull(); + }); + }); +}); diff --git a/npm/ng-packs/packages/core/src/lib/utils/index.ts b/npm/ng-packs/packages/core/src/lib/utils/index.ts index 9b542ec5b4d..2a03a36c4ea 100644 --- a/npm/ng-packs/packages/core/src/lib/utils/index.ts +++ b/npm/ng-packs/packages/core/src/lib/utils/index.ts @@ -15,6 +15,8 @@ export * from './multi-tenancy-utils'; export * from './number-utils'; export * from './object-utils'; export * from './queue'; +export * from './route-based-culture.utils'; +export * from './route-culture-url-matcher'; export * from './route-utils'; export * from './string-utils'; export * from './tree-utils'; diff --git a/npm/ng-packs/packages/core/src/lib/utils/initial-utils.ts b/npm/ng-packs/packages/core/src/lib/utils/initial-utils.ts index 414f4d0a31c..1534648a728 100644 --- a/npm/ng-packs/packages/core/src/lib/utils/initial-utils.ts +++ b/npm/ng-packs/packages/core/src/lib/utils/initial-utils.ts @@ -1,4 +1,4 @@ -import { registerLocaleData } from '@angular/common'; +import { Location, registerLocaleData } from '@angular/common'; import { inject, Injector } from '@angular/core'; import { tap, catchError } from 'rxjs/operators'; import { firstValueFrom, lastValueFrom, of, throwError, timeout } from 'rxjs'; @@ -7,6 +7,7 @@ import { Environment } from '../models/environment'; import { CurrentTenantDto } from '../proxy/volo/abp/asp-net-core/mvc/multi-tenancy/models'; import { ConfigStateService } from '../services/config-state.service'; import { EnvironmentService } from '../services/environment.service'; +import { RouteBasedCultureService } from '../services/route-based-culture.service'; import { SessionStateService } from '../services/session-state.service'; import { CORE_OPTIONS } from '../tokens/options.token'; import { APP_INIT_ERROR_HANDLERS } from '../tokens/app-config.token'; @@ -63,6 +64,9 @@ export async function getInitialData() { await lastValueFrom(result$); } + const routeBasedCulture = injector.get(RouteBasedCultureService); + routeBasedCulture.syncLanguageFromUrl(injector.get(Location).path()); + await localeInitializer(injector); } diff --git a/npm/ng-packs/packages/core/src/lib/utils/route-based-culture.utils.ts b/npm/ng-packs/packages/core/src/lib/utils/route-based-culture.utils.ts new file mode 100644 index 00000000000..3c0f92b3578 --- /dev/null +++ b/npm/ng-packs/packages/core/src/lib/utils/route-based-culture.utils.ts @@ -0,0 +1,82 @@ +import type { LanguageInfo } from '../proxy/volo/abp/localization/models'; + +/** + * Returns the first path segment of a URL path (leading slashes are ignored). + * Strips query string and fragment first (e.g. "/zh-Hans/account?x=1" → "zh-Hans"). + */ +export function getFirstPathSegment(urlPath: string): string { + if (!urlPath) { + return ''; + } + + const suffixIndex = urlPath.search(/[?#]/); + const pathPart = suffixIndex >= 0 ? urlPath.slice(0, suffixIndex) : urlPath; + const segments = pathPart.split('/').filter(s => s.length > 0); + + return segments[0] ?? ''; +} + +/** + * If the first segment matches a configured language culture name (case-insensitive), + * returns the canonical culture name from configuration; otherwise undefined. + */ +export function findMatchingCultureName( + firstSegment: string, + languages: LanguageInfo[] | undefined, +): string | undefined { + if (!firstSegment || !languages?.length) { + return undefined; + } + + const normalized = firstSegment.toLowerCase(); + const match = languages.find( + lang => lang.cultureName && lang.cultureName.toLowerCase() === normalized, + ); + + return match?.cultureName; +} + +/** + * Removes the first path segment when it matches a configured UI culture (e.g. `/en/identity/users` → `/identity/users`). + * Use when comparing the **browser URL** to **menu links** that omit the culture segment. + */ +export function stripCultureSegmentFromPath( + path: string, + languages: LanguageInfo[] | undefined, +): string { + if (!path || !languages?.length) { + return path; + } + + const pathEnd = path.search(/[?#]/); + const pathOnly = pathEnd >= 0 ? path.slice(0, pathEnd) : path; + const suffix = pathEnd >= 0 ? path.slice(pathEnd) : ''; + + const first = getFirstPathSegment(pathOnly); + if (!first || !findMatchingCultureName(first, languages)) { + return path; + } + + const segments = pathOnly.split('/').filter(s => s.length > 0); + if (segments.length === 0) { + return path; + } + + const rest = segments.slice(1); + const normalized = rest.length ? '/' + rest.join('/') : '/'; + return normalized + suffix; +} + +/** + * When route-based culture is enabled, normalizes URLs for comparison with menu routes that do not include a culture prefix. + */ +export function normalizeUrlForRouteCultureMatch( + path: string, + useRouteBasedCulture: boolean, + languages: LanguageInfo[] | undefined, +): string { + if (!useRouteBasedCulture || !path) { + return path; + } + return stripCultureSegmentFromPath(path, languages); +} diff --git a/npm/ng-packs/packages/core/src/lib/utils/route-culture-url-matcher.ts b/npm/ng-packs/packages/core/src/lib/utils/route-culture-url-matcher.ts new file mode 100644 index 00000000000..0093c5cb675 --- /dev/null +++ b/npm/ng-packs/packages/core/src/lib/utils/route-culture-url-matcher.ts @@ -0,0 +1,59 @@ +import { Route, Routes, UrlMatchResult, UrlSegment, UrlSegmentGroup } from '@angular/router'; + +/** + * Heuristic: first path segment looks like a BCP 47-style culture code (e.g. en, tr, zh-Hans, pt-BR). + * Used by the optional culture URL matcher so real routes like `identity` or `cms-kit` are not mistaken for cultures. + */ +export function isLikelyCultureSegment(segment: string): boolean { + if (!segment) { + return false; + } + + return /^[a-z]{2}(-[a-zA-Z0-9]{2,8})?$/.test(segment); +} + +/** + * Matcher that consumes the first segment when it looks like a culture code. + * Exposes it as the route param `culture` on matched routes. + */ +export function createRouteCultureUrlMatcher(): ( + segments: UrlSegment[], + group: UrlSegmentGroup, + route: Route, +) => UrlMatchResult | null { + return (segments: UrlSegment[]) => { + if (segments.length < 1) { + return null; + } + + const first = segments[0].path; + if (!isLikelyCultureSegment(first)) { + return null; + } + + return { + consumed: [segments[0]], + posParams: { culture: segments[0] }, + }; + }; +} + +/** + * Wraps your app routes so the same URLs work with or without a leading culture segment. + * + * Examples (same components for both shapes): + * - `/` and `/en` + * - `/home` and `/en/home` + * - `/identity/users` and `/en/identity/users` + * + * The culture segment is matched only when it passes {@link isLikelyCultureSegment} (e.g. `en`, `tr-TR`, `zh-Hans`). + * Session language from the URL is still applied only when `localization.useRouteBasedCulture` is true (`RouteBasedCultureService`). + */ +export function withOptionalRouteCulturePrefix(routes: Routes): Routes { + const matcher = createRouteCultureUrlMatcher(); + + return [ + { matcher, children: [...routes] }, + { path: '', children: [...routes] }, + ]; +} diff --git a/npm/ng-packs/packages/theme-basic/src/lib/components/routes/routes.component.html b/npm/ng-packs/packages/theme-basic/src/lib/components/routes/routes.component.html index afc498d5ac4..e6475affa12 100644 --- a/npm/ng-packs/packages/theme-basic/src/lib/components/routes/routes.component.html +++ b/npm/ng-packs/packages/theme-basic/src/lib/components/routes/routes.component.html @@ -6,7 +6,7 @@