Browse Source

Merge remote-tracking branch 'origin/dev' into feat/mudblazor

pull/25235/head
maliming 6 months ago
parent
commit
9fe5214e1f
No known key found for this signature in database GPG Key ID: A646B9CB645ECEA4
  1. 20
      .github/workflows/auto-pr.yml
  2. 65
      .github/workflows/update-studio-docs.yml
  3. 8
      Directory.Packages.props
  4. 4
      common.props
  5. 72
      docs/en/Blog-Posts/2026-03-31 v10_2_Release_Stable/POST.md
  6. BIN
      docs/en/Blog-Posts/2026-03-31 v10_2_Release_Stable/cover-image.png
  7. BIN
      docs/en/Blog-Posts/2026-03-31 v10_2_Release_Stable/upgrade-abp-packages.png
  8. 254
      docs/en/Blog-Posts/2026-04-01 v10_3_Preview/POST.md
  9. BIN
      docs/en/Blog-Posts/2026-04-01 v10_3_Preview/cover-image.png
  10. BIN
      docs/en/Blog-Posts/2026-04-01 v10_3_Preview/studio-switch-to-preview.png
  11. 10
      docs/en/Community-Articles/2026-03-10-Tutorial-Validator/article.md
  12. 35
      docs/en/Community-Articles/2026-03-23-Dynamic-Events-in-ABP/POST.md
  13. 167
      docs/en/Community-Articles/2026-03-29-Url-Based-Localization/POST.md
  14. BIN
      docs/en/Community-Articles/2026-03-29-Url-Based-Localization/cover.png
  15. BIN
      docs/en/Community-Articles/2026-03-29-Url-Based-Localization/images/blazor-server-zh-hans.png
  16. BIN
      docs/en/Community-Articles/2026-03-29-Url-Based-Localization/images/blazor-webapp-tr.png
  17. BIN
      docs/en/Community-Articles/2026-03-29-Url-Based-Localization/images/module-identity-users.png
  18. BIN
      docs/en/Community-Articles/2026-03-29-Url-Based-Localization/images/mvc-home-en.png
  19. BIN
      docs/en/Community-Articles/2026-03-29-Url-Based-Localization/images/mvc-home-tr.png
  20. 1
      docs/en/framework/fundamentals/index.md
  21. 4
      docs/en/framework/fundamentals/localization.md
  22. 173
      docs/en/framework/fundamentals/url-based-localization.md
  23. 49
      docs/en/framework/infrastructure/event-bus/distributed/index.md
  24. BIN
      docs/en/images/url-based-localization-angular-menu-url.png
  25. BIN
      docs/en/images/url-based-localization-angular-routes.png
  26. 15
      docs/en/package-version-changes.md
  27. 76
      docs/en/release-info/migration-guides/abp-10-3.md
  28. 1
      docs/en/release-info/migration-guides/index.md
  29. 16
      docs/en/release-info/release-notes.md
  30. 23
      docs/en/release-info/road-map.md
  31. 7
      docs/en/studio/release-notes.md
  32. 2
      docs/en/studio/version-mapping.md
  33. 9
      docs/en/tutorials/modular-crm/index.md
  34. 11
      docs/en/tutorials/modular-crm/part-01.md
  35. 9
      docs/en/tutorials/modular-crm/part-02.md
  36. 11
      docs/en/tutorials/modular-crm/part-03.md
  37. 15
      docs/en/tutorials/modular-crm/part-04.md
  38. 11
      docs/en/tutorials/modular-crm/part-05.md
  39. 19
      docs/en/tutorials/modular-crm/part-06.md
  40. 9
      docs/en/tutorials/modular-crm/part-07.md
  41. 13
      docs/en/tutorials/modular-crm/part-08.md
  42. 63
      framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/AbpWasmCultureMenuItemUrlProvider.cs
  43. 25
      framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/CultureAwareAuthenticationBase.cs
  44. 35
      framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/CultureAwareRedirectToLoginHelper.cs
  45. 4
      framework/src/Volo.Abp.AspNetCore.Components.WebAssembly.Theming/Volo.Abp.AspNetCore.Components.WebAssembly.Theming.csproj
  46. 14
      framework/src/Volo.Abp.AspNetCore.Components.WebAssembly/Volo/Abp/AspNetCore/Components/WebAssembly/IRouteBasedCultureNavigationHelper.cs
  47. 14
      framework/src/Volo.Abp.AspNetCore.Components.WebAssembly/Volo/Abp/AspNetCore/Components/WebAssembly/IRouteBasedCultureUrlHelper.cs
  48. 56
      framework/src/Volo.Abp.AspNetCore.Components.WebAssembly/Volo/Abp/AspNetCore/Components/WebAssembly/RouteBasedCultureNavigationHelper.cs
  49. 90
      framework/src/Volo.Abp.AspNetCore.Components.WebAssembly/Volo/Abp/AspNetCore/Components/WebAssembly/RouteBasedCultureUrlHelper.cs
  50. 2
      framework/src/Volo.Abp.AspNetCore.Mvc.Contracts/Volo/Abp/AspNetCore/Mvc/ApplicationConfigurations/ApplicationLocalizationConfigurationDto.cs
  51. 41
      framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/AbpAspNetCoreMvcModule.cs
  52. 5
      framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/ApplicationConfigurations/AbpApplicationConfigurationAppService.cs
  53. 84
      framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpAspNetCoreMvcQueryStringCultureReplacement.cs
  54. 71
      framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureAwareUrlHelper.cs
  55. 69
      framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureMenuItemUrlProvider.cs
  56. 37
      framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureRouteConstraint.cs
  57. 47
      framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureRoutePagesConvention.cs
  58. 49
      framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureRouteUrlHelperFactory.cs
  59. 24
      framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/AbpLanguagesController.cs
  60. 11
      framework/src/Volo.Abp.AspNetCore.Mvc/Volo/Abp/AspNetCore/Mvc/Localization/QueryStringCultureReplacementContext.cs
  61. 47
      framework/src/Volo.Abp.AspNetCore/Microsoft/AspNetCore/RequestLocalization/AbpRequestCultureCookieHelper.cs
  62. 21
      framework/src/Volo.Abp.AspNetCore/Microsoft/AspNetCore/RequestLocalization/AbpRequestLocalizationMiddleware.cs
  63. 6
      framework/src/Volo.Abp.AspNetCore/Microsoft/AspNetCore/RequestLocalization/AbpRequestLocalizationOptions.cs
  64. 16
      framework/src/Volo.Abp.AspNetCore/Microsoft/AspNetCore/RequestLocalization/DefaultAbpRequestLocalizationOptionsProvider.cs
  65. 17
      framework/src/Volo.Abp.Autofac/Autofac/Extensions/DependencyInjection/AutofacRegistration.cs
  66. 16
      framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Utils/CmdHelper.cs
  67. 12
      framework/src/Volo.Abp.TextTemplating.Scriban/Volo/Abp/TextTemplating/Scriban/ScribanTemplateLocalizer.cs
  68. 8
      framework/src/Volo.Abp.UI.Navigation/Volo/Abp/Ui/Navigation/IMenuItemCulturePrefixHelper.cs
  69. 13
      framework/src/Volo.Abp.UI.Navigation/Volo/Abp/Ui/Navigation/IMenuItemUrlProvider.cs
  70. 33
      framework/src/Volo.Abp.UI.Navigation/Volo/Abp/Ui/Navigation/MenuItemCulturePrefixHelper.cs
  71. 11
      framework/src/Volo.Abp.UI.Navigation/Volo/Abp/Ui/Navigation/MenuItemUrlProviderContext.cs
  72. 6
      framework/src/Volo.Abp.UI.Navigation/Volo/Abp/Ui/Navigation/MenuManager.cs
  73. 1
      framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo.Abp.AspNetCore.Mvc.Tests.csproj
  74. 6
      framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/AbpAspNetCoreMvcTestModule.cs
  75. 137
      framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureAwareUrlHelper_Tests.cs
  76. 280
      framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/AbpCultureMenuItemUrlProvider_Tests.cs
  77. 123
      framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/AbpLanguagesController_Tests.cs
  78. 259
      framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/LanguageSwitchRouteCultureReplacement_Tests.cs
  79. 48
      framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/RouteBasedCultureApiRouting_Tests.cs
  80. 103
      framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/RouteBasedCultureNavigationHelper_Tests.cs
  81. 156
      framework/test/Volo.Abp.AspNetCore.Mvc.Tests/Volo/Abp/AspNetCore/Mvc/Localization/RouteBasedCultureUrlHelper_Tests.cs
  82. 53
      framework/test/Volo.Abp.AspNetCore.Tests/Volo/Abp/AspNetCore/Localization/RouteBasedCultureTestModule.cs
  83. 75
      framework/test/Volo.Abp.AspNetCore.Tests/Volo/Abp/AspNetCore/Localization/RouteBasedCulture_Tests.cs
  84. 157
      framework/test/Volo.Abp.Autofac.Tests/Volo/Abp/Autofac/AutofacRegistration_Tests.cs
  85. 47
      framework/test/Volo.Abp.Cli.Core.Tests/Volo/Abp/Cli/Utils/CmdHelper_Tests.cs
  86. 110
      framework/test/Volo.Abp.EventBus.Tests/Volo/Abp/EventBus/Distributed/LocalDistributedEventBus_Test.cs
  87. 9
      latest-versions.json
  88. 2
      modules/account/src/Volo.Abp.Account.Blazor/AbpAccountBlazorUserMenuContributor.cs
  89. 1
      modules/account/src/Volo.Abp.Account.Blazor/Pages/Account/AccountManage.razor
  90. 3
      modules/account/src/Volo.Abp.Account.Blazor/Pages/Account/AccountManage.razor.cs
  91. 2
      modules/basic-theme/src/Volo.Abp.AspNetCore.Components.Server.BasicTheme/Themes/Basic/LoginDisplay.razor
  92. 11
      modules/basic-theme/src/Volo.Abp.AspNetCore.Components.Server.BasicTheme/Themes/Basic/LoginDisplay.razor.cs
  93. 4
      modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/BasicThemeToolbarContributor.cs
  94. 18
      modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/Themes/Basic/LanguageSwitch.razor
  95. 2
      modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/Themes/Basic/LoginDisplay.razor
  96. 25
      modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/Themes/Basic/LoginDisplay.razor.cs
  97. 21
      modules/basic-theme/src/Volo.Abp.AspNetCore.Components.WebAssembly.BasicTheme/Themes/Basic/WebAssemblyRedirectToLogin.razor
  98. 6
      modules/basic-theme/test/Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.Demo/package.json
  99. 266
      modules/basic-theme/test/Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.Demo/yarn.lock
  100. 4
      modules/basic-theme/test/Volo.Abp.AspNetCore.Mvc.UI.Theme.Basic.Demo/package.json

20
.github/workflows/auto-pr.yml

@ -1,13 +1,13 @@
name: Merge branch dev with rel-10.2
name: Merge branch dev with rel-10.3
on:
push:
branches:
- rel-10.2
- rel-10.3
permissions:
contents: read
jobs:
merge-dev-with-rel-10-2:
merge-dev-with-rel-10-3:
permissions:
contents: write # for peter-evans/create-pull-request to create branch
pull-requests: write # for peter-evans/create-pull-request to create a PR
@ -18,14 +18,14 @@ jobs:
ref: dev
- name: Reset promotion branch
run: |
git fetch origin rel-10.2:rel-10.2
git reset --hard rel-10.2
git fetch origin rel-10.3:rel-10.3
git reset --hard rel-10.3
- name: Create Pull Request
uses: peter-evans/create-pull-request@v3
with:
branch: auto-merge/rel-10-2/${{github.run_number}}
title: Merge branch dev with rel-10.2
body: This PR generated automatically to merge dev with rel-10.2. Please review the changed files before merging to prevent any errors that may occur.
branch: auto-merge/rel-10-3/${{github.run_number}}
title: Merge branch dev with rel-10.3
body: This PR generated automatically to merge dev with rel-10.3. Please review the changed files before merging to prevent any errors that may occur.
draft: true
token: ${{ github.token }}
- name: Merge Pull Request
@ -33,5 +33,5 @@ jobs:
GH_TOKEN: ${{ secrets.BOT_SECRET }}
run: |
gh pr ready
gh pr review auto-merge/rel-10-2/${{github.run_number}} --approve
gh pr merge auto-merge/rel-10-2/${{github.run_number}} --merge --auto --delete-branch
gh pr review auto-merge/rel-10-3/${{github.run_number}} --approve
gh pr merge auto-merge/rel-10-3/${{github.run_number}} --merge --auto --delete-branch

65
.github/workflows/update-studio-docs.yml

@ -18,9 +18,9 @@ on:
description: 'Release URL'
required: true
target_branch:
description: 'Target branch (default: dev)'
description: 'Target branch (leave empty to auto-detect from latest stable ABP release)'
required: false
default: 'dev'
default: ''
jobs:
update-docs:
@ -41,7 +41,7 @@ jobs:
echo "version=${{ github.event.client_payload.version }}" >> $GITHUB_OUTPUT
echo "name=${{ github.event.client_payload.name }}" >> $GITHUB_OUTPUT
echo "url=${{ github.event.client_payload.url }}" >> $GITHUB_OUTPUT
echo "target_branch=${{ github.event.client_payload.target_branch || 'dev' }}" >> $GITHUB_OUTPUT
echo "target_branch=${{ github.event.client_payload.target_branch }}" >> $GITHUB_OUTPUT
# Save notes to environment variable (multiline)
{
@ -53,7 +53,7 @@ jobs:
echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT
echo "name=${{ github.event.inputs.name }}" >> $GITHUB_OUTPUT
echo "url=${{ github.event.inputs.url }}" >> $GITHUB_OUTPUT
echo "target_branch=${{ github.event.inputs.target_branch || 'dev' }}" >> $GITHUB_OUTPUT
echo "target_branch=${{ github.event.inputs.target_branch }}" >> $GITHUB_OUTPUT
# Save notes to environment variable (multiline)
{
@ -63,12 +63,55 @@ jobs:
} >> $GITHUB_ENV
fi
# -------------------------------------------------
# Resolve target branch (auto-detect from latest stable ABP if not provided)
# -------------------------------------------------
- name: Resolve target branch
id: resolve_branch
run: |
TARGET_BRANCH="${{ steps.payload.outputs.target_branch }}"
if [ -z "$TARGET_BRANCH" ]; then
echo "🔍 No target_branch provided - fetching latest stable ABP release..."
RELEASES=$(curl -fsS \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
"https://api.github.com/repos/abpframework/abp/releases?per_page=20")
ABP_VERSION=$(echo "$RELEASES" | jq -r '
[.[] | select(
(.prerelease == false) and
(.tag_name | test("preview|rc|beta|dev"; "i") | not)
)] | first | .tag_name
')
if [ -z "$ABP_VERSION" ] || [ "$ABP_VERSION" = "null" ]; then
echo "❌ Could not determine latest stable ABP version"
exit 1
fi
# Derive rel-X.Y from X.Y.Z (e.g., 10.1.1 -> rel-10.1)
TARGET_BRANCH=$(echo "$ABP_VERSION" | grep -oE '^[0-9]+\.[0-9]+' | sed 's/^/rel-/')
if [ -z "$TARGET_BRANCH" ]; then
echo "❌ Could not derive target branch from version: $ABP_VERSION"
exit 1
fi
echo "✅ Auto-detected target branch: $TARGET_BRANCH (from ABP $ABP_VERSION)"
else
echo "✅ Using provided target branch: $TARGET_BRANCH"
fi
echo "target_branch=$TARGET_BRANCH" >> $GITHUB_OUTPUT
- name: Validate payload
env:
VERSION: ${{ steps.payload.outputs.version }}
NAME: ${{ steps.payload.outputs.name }}
URL: ${{ steps.payload.outputs.url }}
TARGET_BRANCH: ${{ steps.payload.outputs.target_branch }}
TARGET_BRANCH: ${{ steps.resolve_branch.outputs.target_branch }}
run: |
if [ -z "$VERSION" ] || [ "$VERSION" = "null" ]; then
echo "❌ Missing: version"
@ -98,7 +141,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ steps.payload.outputs.target_branch }}
ref: ${{ steps.resolve_branch.outputs.target_branch }}
fetch-depth: 0
- name: Configure git
@ -563,7 +606,7 @@ jobs:
VERSION: ${{ steps.payload.outputs.version }}
NAME: ${{ steps.payload.outputs.name }}
URL: ${{ steps.payload.outputs.url }}
TARGET_BRANCH: ${{ steps.payload.outputs.target_branch }}
TARGET_BRANCH: ${{ steps.resolve_branch.outputs.target_branch }}
run: |
# Check for existing PR
EXISTING_PR=$(gh pr list \
@ -593,7 +636,8 @@ jobs:
gh pr edit "$EXISTING_PR" \
--title "docs(studio): release $VERSION - $NAME" \
--body "$PR_BODY"
--body "$PR_BODY" \
--add-reviewer skoc10
echo "PR_NUMBER=$EXISTING_PR" >> $GITHUB_ENV
else
@ -605,7 +649,8 @@ jobs:
--title "docs(studio): release $VERSION - $NAME" \
--body "$PR_BODY" \
--base "$TARGET_BRANCH" \
--head "$BRANCH")
--head "$BRANCH" \
--reviewer skoc10)
PR_NUMBER=$(echo "$PR_URL" | grep -oE '[0-9]+$')
echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_ENV
@ -643,7 +688,7 @@ jobs:
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Version**: $VERSION" >> $GITHUB_STEP_SUMMARY
echo "**Release**: ${{ steps.payload.outputs.name }}" >> $GITHUB_STEP_SUMMARY
echo "**Target Branch**: ${{ steps.payload.outputs.target_branch }}" >> $GITHUB_STEP_SUMMARY
echo "**Target Branch**: ${{ steps.resolve_branch.outputs.target_branch }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ "${{ steps.changes.outputs.has_changes }}" = "true" ]; then

8
Directory.Packages.props

@ -7,8 +7,8 @@
<PackageVersion Include="AlibabaCloud.SDK.Dysmsapi20170525" Version="4.0.0" />
<PackageVersion Include="aliyun-net-sdk-sts" Version="3.1.3" />
<PackageVersion Include="Aliyun.OSS.SDK.NetCore" Version="2.14.1" />
<PackageVersion Include="Autofac" Version="8.4.0" />
<PackageVersion Include="Autofac.Extensions.DependencyInjection" Version="10.0.0" />
<PackageVersion Include="Autofac" Version="9.1.0" />
<PackageVersion Include="Autofac.Extensions.DependencyInjection" Version="11.0.0" />
<PackageVersion Include="Autofac.Extras.DynamicProxy" Version="7.1.0" />
<PackageVersion Include="AutoMapper" Version="14.0.0" />
<PackageVersion Include="Asp.Versioning.Mvc" Version="8.1.0" />
@ -77,7 +77,7 @@
<PackageVersion Include="Microsoft.AspNetCore.Razor.Language" Version="6.0.36" />
<PackageVersion Include="Microsoft.AspNetCore.TestHost" Version="10.0.2" />
<PackageVersion Include="Microsoft.AspNetCore.WebUtilities" Version="10.0.2" />
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.2" />
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.4" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.5.0" />
<PackageVersion Include="Microsoft.CSharp" Version="4.7.0" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.2" />
@ -151,7 +151,7 @@
<PackageVersion Include="Rebus" Version="8.8.0" />
<PackageVersion Include="Rebus.ServiceProvider" Version="10.5.0" />
<PackageVersion Include="Riok.Mapperly" Version="4.3.1" />
<PackageVersion Include="Scriban" Version="6.6.0" />
<PackageVersion Include="Scriban" Version="7.0.0" />
<PackageVersion Include="Serilog" Version="4.3.0" />
<PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageVersion Include="Serilog.Extensions.Hosting" Version="9.0.0" />

4
common.props

@ -1,8 +1,8 @@
<Project>
<PropertyGroup>
<LangVersion>latest</LangVersion>
<Version>10.3.0-preview</Version>
<LeptonXVersion>5.3.0-preview</LeptonXVersion>
<Version>10.4.0-preview</Version>
<LeptonXVersion>5.4.0-preview</LeptonXVersion>
<NoWarn>$(NoWarn);CS1591;CS0436</NoWarn>
<PackageIconUrl>https://abp.io/assets/abp_nupkg.png</PackageIconUrl>
<PackageProjectUrl>https://abp.io/</PackageProjectUrl>

72
docs/en/Blog-Posts/2026-03-31 v10_2_Release_Stable/POST.md

@ -0,0 +1,72 @@
# ABP.IO Platform 10.2 Final Has Been Released!
We are glad to announce that [ABP](https://abp.io/) 10.2 stable version has been released.
## What's New With Version 10.2?
All the new features were explained in detail in the [10.2 RC Announcement Post](https://abp.io/community/announcements/announcing-abp-10-2-release-candidate-05zatjfq), so there is no need to review them again. You can check it out for more details.
## Getting Started with 10.2
### How to Upgrade an Existing Solution
You can upgrade your existing solutions with either ABP Studio or ABP CLI. In the following sections, both approaches are explained:
### Upgrading via ABP Studio
If you are already using the ABP Studio, you can upgrade it to the latest version. ABP Studio periodically checks for updates in the background, and when a new version of ABP Studio is available, you will be notified through a modal. Then, you can update it by confirming the opened modal. See [the documentation](https://abp.io/docs/latest/studio/installation#upgrading) for more info.
After upgrading the ABP Studio, then you can open your solution in the application, and simply click the **Upgrade ABP Packages** action button to instantly upgrade your solution:
![](upgrade-abp-packages.png)
### Upgrading via ABP CLI
Alternatively, you can upgrade your existing solution via ABP CLI. First, you need to install the ABP CLI or upgrade it to the latest version.
If you haven't installed it yet, you can run the following command:
```bash
dotnet tool install -g Volo.Abp.Studio.Cli
```
Or to update the existing CLI, you can run the following command:
```bash
dotnet tool update -g Volo.Abp.Studio.Cli
```
After installing/updating the ABP CLI, you can use the [`update` command](https://abp.io/docs/latest/CLI#update) to update all the ABP related NuGet and NPM packages in your solution as follows:
```bash
abp update
```
You can run this command in the root folder of your solution to update all ABP related packages.
## Migration Guides
There are a few breaking changes in this version that may affect your application. Please read the migration guide carefully, if you are upgrading from v10.1 or earlier versions: [ABP Version 10.2 Migration Guide](https://abp.io/docs/10.2/release-info/migration-guides/abp-10-2)
## Community News
### New ABP Community Articles
As always, exciting articles have been contributed by the ABP community. I will highlight some of them here:
- [Liming Ma](https://abp.io/community/members/maliming) has published 6 new posts:
- [Dynamic Events in ABP](https://abp.io/community/articles/dynamic-events-in-abp-dukq95m1)
- [Dynamic Background Jobs and Workers in ABP](https://abp.io/community/articles/dynamic-background-jobs-and-workers-in-abp-wfdkdsq9)
- [Shared User Accounts in ABP Multi-Tenancy](https://abp.io/community/articles/shared-user-accounts-in-abp-multitenancy-mf3bkg79)
- [Secure Client Authentication with private_key_jwt in ABP 10.3](https://abp.io/community/articles/secure-client-authentication-with-privatekeyjwt-in-abp-b2rf18bc)
- [Operation Rate Limiting in ABP Framework](https://abp.io/community/articles/operation-rate-limiting-in-abp-framework-f4jtd6sn)
- [Resource-Based Authorization in ABP Framework](https://abp.io/community/articles/resourcebased-authorization-in-abp-framework-choku1sn)
- [One Endpoint, Many AI Clients: Turning ABP Workspaces into OpenAI-Compatible Models](https://abp.io/community/articles/turning-abp-workspaces-into-openai-compatible-endpoints-u3ls1gp4) by [Engincan Veske](https://abp.io/community/members/EngincanV)
- [Automatically Validate Your Documentation: How We Built a Tutorial Validator](https://abp.io/community/articles/automatically-validate-your-documentation-m3ozgkhv) by [Mansur Besleney](https://abp.io/community/members/mansur.besleney)
- [Automate Localhost Access for Expo: A Guide to Dynamic Cloudflare Tunnels & Dev Builds](https://abp.io/community/articles/automate-localhost-access-for-expo-a-guide-to-dynamic-7cblqtj3) by [Sumeyye Kurtulus](https://abp.io/community/members/sumeyye.kurtulus)
Thanks to the ABP Community for all the content they have published. You can also [post your ABP related (text or video) content](https://abp.io/community/posts/create) to the ABP Community.
## About the Next Version
The next feature version will be 10.3. You can follow the [release planning here](https://github.com/abpframework/abp/milestones). Please [submit an issue](https://github.com/abpframework/abp/issues/new) if you have any problems with this version.

BIN
docs/en/Blog-Posts/2026-03-31 v10_2_Release_Stable/cover-image.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 428 KiB

BIN
docs/en/Blog-Posts/2026-03-31 v10_2_Release_Stable/upgrade-abp-packages.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

254
docs/en/Blog-Posts/2026-04-01 v10_3_Preview/POST.md

@ -0,0 +1,254 @@
# ABP Platform 10.3 RC Has Been Released
We are happy to release [ABP](https://abp.io) version **10.3 RC** (Release Candidate). This blog post introduces the new features and important changes in this new version.
Try this version and provide feedback for a more stable version of ABP v10.3! Thanks to you in advance.
## Get Started with the 10.3 RC
You can check the [Get Started page](https://abp.io/get-started) to see how to get started with ABP. You can either download [ABP Studio](https://abp.io/get-started#abp-studio-tab) (**recommended**, if you prefer a user-friendly GUI application - desktop application) or use the [ABP CLI](https://abp.io/docs/latest/cli).
By default, ABP Studio uses stable versions to create solutions. Therefore, if you want to create a solution with a preview version, first you need to create a solution and then switch your solution to the preview version from the ABP Studio UI:
![studio-switch-to-preview.png](studio-switch-to-preview.png)
## Migration Guide
There are no explicitly marked breaking changes in this version. However, there are still some important migration notes for specific scenarios. Please check the migration guide if you are upgrading from v10.2 or earlier: [ABP Version 10.3 Migration Guide](https://abp.io/docs/10.3/release-info/migration-guides/abp-10-3).
## What's New with ABP v10.3?
In this section, I will introduce some major features released in this version.
Here is a brief list of titles explained in the next sections:
- OpenIddict: `private_key_jwt` Client Authentication + `abp generate-jwks`
- Event Bus: String-Based Event Publishing with Dynamic Payload
- Background Jobs/Workers: String-Based Publishing with Dynamic Payload
- API Definition Endpoint: Descriptions and Documentation Support
- Entity Cache: New Batch APIs (`FindMany*` / `GetMany*`)
- Angular: User/Tenant Sharing and Tenant Switch Experience
- Angular: Upgrade to 21.2 + TypeScript 5.9
- Introducing the `Volo.Abp.LuckyPenny.AutoMapper` Provider
- Security Improvements (Account Pro Module)
### OpenIddict: `private_key_jwt` Client Authentication + `abp generate-jwks`
ABP v10.3 introduces end-to-end support for OpenIddict `private_key_jwt` client authentication.
Instead of using a shared `client_secret`, clients can now authenticate with an asymmetric key pair: keep the private key on the client, and register the public key (JWKS) on the authorization server.
On the open-source side, ABP CLI now includes the `abp generate-jwks` command (and the OpenIddict demo was updated accordingly). On the Pro side, OpenIddict application management now supports storing and validating JWKS for confidential applications.
This is especially useful for machine-to-machine and compliance-focused environments where shared secrets are not preferred.
**Example - Generate a JWKS with ABP CLI:**
```bash
abp generate-jwks --alg RS256 --key-size 2048 -o ./keys -f my-client
```
> See the community article [Secure Client Authentication with private_key_jwt in ABP 10.3](https://abp.io/community/articles/secure-client-authentication-with-privatekeyjwt-in-abp-b2rf18bc) for a full walkthrough.
> This approach is especially useful for Pro solutions that manage confidential clients in the administration UI.
### Event Bus: String-Based Event Publishing with Dynamic Payload
ABP v10.3 adds string-based publishing and subscription APIs for event-driven integrations.
When you do not know event types at compile time, you can now publish and handle events by name without introducing extra wrapper contracts up front. This is especially useful for plugin ecosystems, partner integrations, and metadata-driven application flows.
This is not a separate eventing model. Dynamic events run through the same ABP infrastructure (including outbox/inbox when configured), can be handled through `DynamicEventData`, and can coexist with typed handlers for the same event name. Distributed providers support this approach except Dapr, which requires startup-time topic declarations.
**Example - Publish by event name:**
```csharp
await _distributedEventBus.PublishAsync(
"OrderPlaced",
new { OrderId = input.Id, CustomerEmail = input.Email }
);
```
**Example - Subscribe dynamically at runtime:**
```csharp
eventBus.Subscribe("PartnerOrderReceived",
new PartnerOrderHandler(context.ServiceProvider));
public class PartnerOrderHandler : IDistributedEventHandler<DynamicEventData>
{
public Task HandleEventAsync(DynamicEventData eventData)
{
// eventData.EventName + eventData.Data
return Task.CompletedTask;
}
}
```
> See the community article [Dynamic Events in ABP](https://abp.io/community/articles/dynamic-events-in-abp-dukq95m1) for details.
### Background Jobs/Workers: String-Based Publishing with Dynamic Payload
ABP v10.3 introduces **Dynamic Background Jobs** (`IDynamicBackgroundJobManager`) and **Dynamic Background Workers** (`IDynamicBackgroundWorkerManager`) for runtime registration and execution by name.
With these APIs, you can enqueue jobs with dynamic payloads, register handler delegates at startup, and add/update/remove recurring workers at runtime. This is especially useful for plugin architectures, metadata-driven workflows, and tenant-specific scheduling scenarios where task types are not known at compile time.
Dynamic background jobs work through ABP's existing typed job pipeline (including provider integrations), while dynamic workers support runtime schedule management (period/cron depending on provider).
**Example - Enqueue a job by name with dynamic payload:**
```csharp
await _dynamicBackgroundJobManager.EnqueueAsync("emails", new
{
EmailAddress = input.CustomerEmail,
Subject = "Order Confirmed",
Body = $"Your order {input.OrderId} has been placed."
});
```
**Example - Update worker schedule at runtime:**
```csharp
await workerManager.UpdateScheduleAsync(
"InventorySyncWorker",
new DynamicBackgroundWorkerSchedule { Period = 10000 } // 10s
);
```
> See [#25059](https://github.com/abpframework/abp/pull/25059) and the community article [Dynamic Background Jobs and Workers in ABP](https://abp.io/community/articles/dynamic-background-jobs-and-workers-in-abp-wfdkdsq9) for details.
### API Definition Endpoint: Descriptions and Documentation Support
The API definition endpoint can now optionally return richer metadata such as summary/description fields for controllers, actions, and parameters.
This is particularly useful for dynamic client generation, API explorers, and tooling that consumes ABP API metadata directly without requiring OpenAPI parsing.
> See [#25022](https://github.com/abpframework/abp/pull/25022) for details.
### Entity Cache: New Batch APIs (`FindMany*` / `GetMany*`)
ABP v10.3 extends `IEntityCache` with batch retrieval APIs so you can resolve multiple entities in a single cache/database flow instead of looping over `FindAsync`/`GetAsync`.
It includes both list-based APIs (`FindManyAsync` / `GetManyAsync`) and dictionary-based APIs (`FindManyAsDictionaryAsync` / `GetManyAsDictionaryAsync`) so you can choose the shape that best matches your access pattern.
**Example - List-based batch retrieval (preserves input order):**
```csharp
var ids = new List<Guid> { id1, id2, id1 };
var products = await _productCache.GetManyAsync(ids); // throws if any ID is missing
var productsOrNull = await _productCache.FindManyAsync(ids); // null for missing IDs
```
**Example - Dictionary-based batch retrieval (fast lookup by ID):**
```csharp
var productsById = await _productCache.GetManyAsDictionaryAsync(ids);
var nullableProductsById = await _productCache.FindManyAsDictionaryAsync(ids);
if (nullableProductsById.TryGetValue(id1, out var product) && product != null)
{
// use product
}
```
All of these methods are optimized for bulk scenarios by internally batching cache misses via distributed cache multi-get/multi-add operations.
> See [#25088](https://github.com/abpframework/abp/pull/25088) and [#25090](https://github.com/abpframework/abp/pull/25090) for details.
### Angular: User/Tenant Sharing and Tenant Switch Experience
ABP v10.3 enhances Angular UX for shared-user multi-tenancy scenarios, including invitation flows, tenant switch UX, and related identity/account integrations.
This improves the out-of-the-box experience for applications using tenant user sharing.
> See [#25051](https://github.com/abpframework/abp/pull/25051) for details.
### Angular: Upgrade to 21.2 + TypeScript 5.9
ABP v10.3 upgrades Angular to **21.2** and TypeScript to **5.9**, bringing the Angular UI stack to the latest ABP-supported frontend baseline.
This helps you stay current with the modern Angular and TypeScript ecosystem while benefiting from newer compiler/tooling improvements and maintaining compatibility with the ABP Angular packages in this release.
> See [#25072](https://github.com/abpframework/abp/pull/25072) for details.
### Introducing the `Volo.Abp.LuckyPenny.AutoMapper` Provider
ABP v10.3 introduces `Volo.Abp.LuckyPenny.AutoMapper` as a new optional provider integration for projects that want to use the LuckyPenny-maintained AutoMapper package.
The existing `Volo.Abp.AutoMapper` package remains unchanged, and migration is straightforward: replace `AbpAutoMapperModule` with `AbpLuckyPennyAutoMapperModule` in your module dependencies while keeping the same ABP-facing namespaces and APIs.
This update also addresses the AutoMapper 14.x vulnerability context ([GHSA-rvv3-g6hj-g44x](https://github.com/advisories/GHSA-rvv3-g6hj-g44x)), and ABP documentation was expanded with installation, usage, and migration guidance. For more information, see the documentation: [LuckyPenny AutoMapper Integration](https://abp.io/docs/10.3/framework/infrastructure/luckypenny-automapper).
### Security Improvements (Account Pro Module)
ABP Commercial v10.3 RC also includes notable account security hardening:
- Optional CAPTCHA for forgot-password flow
- Operation-based rate limiting policies for account confirmation/token operations (including updated/default policies for reset and token endpoints)
- Session revocation after sensitive credential operations (password change/reset/admin reset)
- Stronger profile picture upload validation (allowed extensions, max size, and magic-bytes checks)
These changes are security-focused and are designed to be practical for real projects. Here are the key points and how you can tune them:
- **Forgot-password abuse protection**: You can enable CAPTCHA for forgot-password flows to reduce automated reset attempts.
- **Operation-level rate limiting**: Token/confirmation/reset operations now rely on policy-based limits, so you can centralize and customize limits per operation.
- **Safer session behavior**: Password changes/resets now revoke sessions to reduce risk from stolen or long-lived sessions.
- **Profile picture hardening**: Uploads are checked by extension, size, and file signature (magic bytes), not only by client-provided metadata.
**Example - Tune profile picture upload restrictions:**
```csharp
Configure<AbpProfilePictureOptions>(options =>
{
options.AllowedFileExtensions = new[] { ".jpg", ".jpeg", ".png" };
options.MaxFileSizeInBytes = 2 * 1024 * 1024; // 2 MB
});
```
**Example - Override account operation rate-limiting policies:**
```csharp
Configure<AbpOperationRateLimitingOptions>(options =>
{
options.ConfigurePolicy(
AbpAccountOperationRateLimitPolicies.SendPasswordResetCode,
policy =>
{
policy.ClearRules();
policy.PerHour(5);
policy.PerDay(20);
});
});
```
> See the community article [Operation Rate Limiting in ABP Framework](https://abp.io/community/articles/operation-rate-limiting-in-abp-framework-f4jtd6sn) for conceptual guidance.
### Other Improvements and Enhancements
- **Permission integration endpoint update**: `PermissionIntegrationController.IsGrantedAsync` now uses `HttpPost` for large payload scenarios ([#25177](https://github.com/abpframework/abp/pull/25177)).
- **OpenIddict dependency update**: Upgraded to OpenIddict 7.3.0 ([#25053](https://github.com/abpframework/abp/pull/25053)).
- **Autofac integration update**: Upgraded `Autofac.Extensions.DependencyInjection` to 11.0.0 ([#25190](https://github.com/abpframework/abp/pull/25190)).
- **MongoDB dependency update**: Bumped MongoDB.Driver to 3.7.1 ([#25114](https://github.com/abpframework/abp/pull/25114)).
- **OIDC auth storage options for Angular UI (pro)**: OIDC auth storage is now configurable.
## Community News
### New ABP Community Articles
As always, exciting articles have been contributed by the ABP community. I will highlight some of them here:
- [Liming Ma](https://abp.io/community/members/maliming) has published 6 new posts:
- [Dynamic Events in ABP](https://abp.io/community/articles/dynamic-events-in-abp-dukq95m1)
- [Dynamic Background Jobs and Workers in ABP](https://abp.io/community/articles/dynamic-background-jobs-and-workers-in-abp-wfdkdsq9)
- [Shared User Accounts in ABP Multi-Tenancy](https://abp.io/community/articles/shared-user-accounts-in-abp-multitenancy-mf3bkg79)
- [Secure Client Authentication with private_key_jwt in ABP 10.3](https://abp.io/community/articles/secure-client-authentication-with-privatekeyjwt-in-abp-b2rf18bc)
- [Operation Rate Limiting in ABP Framework](https://abp.io/community/articles/operation-rate-limiting-in-abp-framework-f4jtd6sn)
- [Resource-Based Authorization in ABP Framework](https://abp.io/community/articles/resourcebased-authorization-in-abp-framework-choku1sn)
- [One Endpoint, Many AI Clients: Turning ABP Workspaces into OpenAI-Compatible Models](https://abp.io/community/articles/turning-abp-workspaces-into-openai-compatible-endpoints-u3ls1gp4) by [Engincan Veske](https://abp.io/community/members/EngincanV)
- [Automatically Validate Your Documentation: How We Built a Tutorial Validator](https://abp.io/community/articles/automatically-validate-your-documentation-m3ozgkhv) by [Mansur Besleney](https://abp.io/community/members/mansur.besleney)
- [Automate Localhost Access for Expo: A Guide to Dynamic Cloudflare Tunnels & Dev Builds](https://abp.io/community/articles/automate-localhost-access-for-expo-a-guide-to-dynamic-7cblqtj3) by [Sumeyye Kurtulus](https://abp.io/community/members/sumeyye.kurtulus)
Thanks to the ABP Community for all the content they have published. You can also [post your ABP related (text or video) content](https://abp.io/community/posts/create) to the ABP Community.
## Conclusion
This version comes with some new features and a lot of enhancements to the existing features. You can see the [Road Map](https://abp.io/docs/10.3/release-info/road-map) documentation to learn about the release schedule and planned features for the next releases. Please try ABP v10.3 RC and provide feedback to help us release a more stable version.
Thanks for being a part of this community!

BIN
docs/en/Blog-Posts/2026-04-01 v10_3_Preview/cover-image.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 KiB

BIN
docs/en/Blog-Posts/2026-04-01 v10_3_Preview/studio-switch-to-preview.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

10
docs/en/Community-Articles/2026-03-10-Tutorial-Validator/article.md

@ -1,4 +1,6 @@
# Automatically Validate Your Documentation: How We Built a Tutorial Validator
# Automatically Validate Your Documentation: How We Built an AI Tutorial Validator
> If you're in a hurry and want to quickly check the repository, you can find the source code of the AI Tutorial Validator here 👉 [github.com/abpframework/ai-tutorial-validator](https://github.com/abpframework/ai-tutorial-validator)
Writing a tutorial is difficult. Keeping technical documentation accurate over time is even harder.
If you maintain developer documentation, you probably know the problem: a tutorial that worked a few months ago can silently break after a framework update, dependency change, or a small missing line in a code snippet.
@ -34,7 +36,7 @@ It treats tutorials like testable workflows, ensuring that every step works exac
## How the Tutorial Validator Works?
the tutorial validator validates tutorials using a three-stage pipeline:
The tutorial validator validates tutorials using a three-stage pipeline:
1. **Analyst**: Scrapes tutorial pages and converts instructions into a structured test plan
2. **Executor**: Follows the plan step by step in a clean environment
@ -43,7 +45,7 @@ the tutorial validator validates tutorials using a three-stage pipeline:
![the tutorial validator Analyst](docs/images/image-1.png)
It identifies commands, code edits, HTTP requests, and expected outcomes.
The key idea is simple: if a developer would need to do it, the validator does it too.
The key idea is simple: if a developer needs to do it, the validator does it too.
That includes running terminal commands, editing files, checking HTTP responses, and validating build outcomes.
![the tutorial validator Executor](docs/images/image-2.png)
@ -108,6 +110,6 @@ If your team maintains technical tutorials, this project can give you a practica
---
You can find the source-code of the tutorial validator at this repo 👉 https://github.com/abpframework/tutorial-validator
You can find the source code of the tutorial validator at this repo 👉 [github.com/abpframework/ai-tutorial-validator](https://github.com/abpframework/ai-tutorial-validator)
We would love to hear your feedback, ideas and waiting PRs to improve this application.

35
docs/en/Community-Articles/2026-03-23-Dynamic-Events-in-ABP/POST.md

@ -72,40 +72,45 @@ Publishing by name with `new { OrderId = ..., CustomerEmail = ... }` triggers th
Dynamic subscription lets you register event handlers at runtime, using a string event name.
The recommended approach is to use `IocEventHandlerFactory`, which is the same mechanism ABP uses internally for typed handlers. It creates a new DI scope for each event, resolves a fresh handler instance, calls `HandleEventAsync`, then disposes the scope — so the handler can use normal constructor injection without any manual scope management:
```csharp
public override async Task OnApplicationInitializationAsync(
public override void ConfigureServices(ServiceConfigurationContext context)
{
context.Services.AddTransient<PartnerOrderHandler>();
}
public override void OnApplicationInitialization(
ApplicationInitializationContext context)
{
var eventBus = context.ServiceProvider
.GetRequiredService<IDistributedEventBus>();
var scopeFactory = context.ServiceProvider
.GetRequiredService<IServiceScopeFactory>();
// Subscribe to a dynamic event — no event class needed
eventBus.Subscribe("PartnerOrderReceived",
new PartnerOrderHandler(context.ServiceProvider));
new IocEventHandlerFactory(scopeFactory, typeof(PartnerOrderHandler)));
}
```
The handler implements `IDistributedEventHandler<DynamicEventData>`:
The handler implements `IDistributedEventHandler<DynamicEventData>` and injects its dependencies normally:
```csharp
public class PartnerOrderHandler : IDistributedEventHandler<DynamicEventData>
{
private readonly IServiceProvider _serviceProvider;
private readonly IPartnerOrderProcessor _orderProcessor;
public PartnerOrderHandler(IServiceProvider serviceProvider)
public PartnerOrderHandler(IPartnerOrderProcessor orderProcessor)
{
_serviceProvider = serviceProvider;
_orderProcessor = orderProcessor;
}
public async Task HandleEventAsync(DynamicEventData eventData)
{
// eventData.EventName = "PartnerOrderReceived"
// eventData.Data = the raw payload from the broker
var orderProcessor = _serviceProvider
.GetRequiredService<IPartnerOrderProcessor>();
await orderProcessor.ProcessAsync(eventData.EventName, eventData.Data);
await _orderProcessor.ProcessAsync(eventData.EventName, eventData.Data);
}
}
```
@ -115,18 +120,22 @@ public class PartnerOrderHandler : IDistributedEventHandler<DynamicEventData>
- **`EventName`** — the string name that identifies the event
- **`Data`** — the raw event data payload (the deserialized `object` from the broker)
> `Subscribe` returns an `IDisposable`. Call `Dispose()` to unsubscribe the handler at runtime.
> `Subscribe` returns an `IDisposable`. Call `Dispose()` to unsubscribe the handler at runtime. For application-lifetime subscriptions, prefer module initialization (`OnApplicationInitialization` / `OnApplicationInitializationAsync`) over subscribing inside an application service.
## Mixed Typed and Dynamic Handlers
Typed and dynamic handlers coexist naturally. When both are registered for the same event name, **both are triggered** — the framework automatically converts the data to the appropriate format for each handler.
```csharp
var eventBus = context.ServiceProvider.GetRequiredService<IDistributedEventBus>();
var scopeFactory = context.ServiceProvider.GetRequiredService<IServiceScopeFactory>();
// Typed handler — receives OrderPlacedEto
eventBus.Subscribe<OrderPlacedEto, OrderEmailHandler>();
// Dynamic handler — receives DynamicEventData for the same event
eventBus.Subscribe("OrderPlaced", new AuditLogHandler());
eventBus.Subscribe("OrderPlaced",
new IocEventHandlerFactory(scopeFactory, typeof(AuditLogHandler)));
```
When `OrderPlacedEto` is published (by type or by name), both handlers fire. The typed handler receives a fully deserialized `OrderPlacedEto` object. The dynamic handler receives a `DynamicEventData` wrapping the raw payload.

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

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

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

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

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

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

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

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

49
docs/en/framework/infrastructure/event-bus/distributed/index.md

@ -757,7 +757,44 @@ await distributedEventBus.PublishAsync(
### Subscribing to Dynamic Events
Use the `Subscribe` overload that accepts a string event name:
The recommended way to subscribe is to implement `IDistributedEventHandler<DynamicEventData>` and use `IocEventHandlerFactory`. This mirrors how ABP manages typed handlers — it creates a new DI scope per event, resolves a fresh handler instance, calls `HandleEventAsync`, then disposes the scope:
````csharp
public override void ConfigureServices(ServiceConfigurationContext context)
{
context.Services.AddTransient<MyDynamicEventHandler>();
}
public override void OnApplicationInitialization(ApplicationInitializationContext context)
{
var eventBus = context.ServiceProvider.GetRequiredService<IDistributedEventBus>();
var scopeFactory = context.ServiceProvider.GetRequiredService<IServiceScopeFactory>();
eventBus.Subscribe("MyDynamicEvent", new IocEventHandlerFactory(scopeFactory, typeof(MyDynamicEventHandler)));
}
````
The handler uses normal constructor injection — no manual scope management needed:
````csharp
public class MyDynamicEventHandler : IDistributedEventHandler<DynamicEventData>
{
private readonly IMyService _myService;
public MyDynamicEventHandler(IMyService myService)
{
_myService = myService;
}
public async Task HandleEventAsync(DynamicEventData eventData)
{
await _myService.ProcessAsync(eventData.EventName, eventData.Data);
}
}
````
`Subscribe` returns an `IDisposable`. Call `Dispose()` to unsubscribe at runtime.
For simple stateless handlers that do not need DI services, you can also use `SingleInstanceHandlerFactory` with an inline handler:
````csharp
var subscription = distributedEventBus.Subscribe(
@ -765,10 +802,8 @@ var subscription = distributedEventBus.Subscribe(
new SingleInstanceHandlerFactory(
new ActionEventHandler<DynamicEventData>(eventData =>
{
// Access the event name and raw data
var name = eventData.EventName;
var data = eventData.Data;
return Task.CompletedTask;
})));
@ -776,13 +811,7 @@ var subscription = distributedEventBus.Subscribe(
subscription.Dispose();
````
You can also subscribe using a typed distributed event handler:
````csharp
distributedEventBus.Subscribe("MyDynamicEvent", myDistributedEventHandler);
````
Where `myDistributedEventHandler` implements `IDistributedEventHandler<DynamicEventData>`.
> Do not inject `IServiceProvider` directly into a `SingleInstanceHandlerFactory`-based handler. Since the same instance is reused for every event, resolving scoped services directly from the root container causes a captive dependency and may throw a scope validation exception in development. Use `IocEventHandlerFactory` instead.
### Mixed Typed and Dynamic Handlers

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

15
docs/en/package-version-changes.md

@ -9,6 +9,20 @@
## 10.3.0-rc.1
| Package | Old Version | New Version | PR |
|---------|-------------|-------------|-----|
| Autofac | 8.4.0 | 9.1.0 | #25190 |
| Autofac.Extensions.DependencyInjection | 10.0.0 | 11.0.0 | #25190 |
| Microsoft.Bcl.AsyncInterfaces | 10.0.2 | 10.0.4 | #25190 |
## 10.2.0-rc.4
| Package | Old Version | New Version | PR |
|---------|-------------|-------------|-----|
| Scriban | 6.6.0 | 7.0.0 | #25170 |
## 10.2.0-rc.3
| Package | Old Version | New Version | PR |
|---------|-------------|-------------|-----|
| Microsoft.IdentityModel.JsonWebTokens | 8.14.0 | 8.16.0 | #25068 |
@ -52,3 +66,4 @@
|---------|-------------|-------------|-----|
| Microsoft.SemanticKernel | 1.67.1 | 1.71.0 | [#24891](https://github.com/abpframework/abp/pull/24891) |
| Microsoft.SemanticKernel.Abstractions | 1.67.1 | 1.71.0 | [#24891](https://github.com/abpframework/abp/pull/24891) |

76
docs/en/release-info/migration-guides/abp-10-3.md

@ -0,0 +1,76 @@
```json
//[doc-seo]
{
"Description": "Upgrade your ABP solutions from v10.2 to v10.3 with this migration guide covering important behavior and integration changes."
}
```
# ABP Version 10.3 Migration Guide
This document is a guide for upgrading ABP v10.2 solutions to ABP v10.3. There are no explicitly marked breaking changes in this release scope, but there are some important changes that may require action in specific application scenarios.
> **Package Version Changes:** Before upgrading, review the [Package Version Changes](../../package-version-changes.md) document to see version changes on dependent NuGet packages and align your project with ABP's internal package versions.
## Open-Source (Framework)
This version contains the following changes on the open-source side:
### `PermissionIntegrationController.IsGrantedAsync` switched to `HttpPost`
**Who is affected**
- Applications that call the permission integration endpoint directly (custom HTTP clients, custom JS, custom integrations).
**What changed**
- `PermissionIntegrationController.IsGrantedAsync` is now `HttpPost` instead of `HttpGet` to avoid URL-length limitations for large permission/user payloads.
**What to do**
- Update custom clients/integrations to send a POST request with payload in the request body.
- If you rely on generated ABP clients, regenerate/update client proxies after upgrading.
> See [#25177](https://github.com/abpframework/abp/pull/25177) for details.
### `IEntityCache` interface updates for batch dictionary APIs
**Who is affected**
- Applications with custom `IEntityCache` implementations/derivations or custom abstractions built on top of `IEntityCache`.
**What changed**
- New batch methods were added (`FindManyAsync` / `GetManyAsync`, and dictionary variants).
- `IEntityCache<TEntityCacheItem, TKey>` no longer uses contravariant `in` on `TKey`.
- `TKey` now has a `notnull` constraint to support dictionary-based APIs.
**What to do**
- Update custom interfaces and implementations to match the latest `IEntityCache` signature and generic constraints.
- Rebuild solution and fix compile-time variance/constraint issues in custom cache abstractions.
> See [#25088](https://github.com/abpframework/abp/pull/25088) and [#25090](https://github.com/abpframework/abp/pull/25090) for details.
## Pro
There are no explicitly marked breaking changes on the PRO side in this release scope. However, check the following if they apply to your application.
### Account Pro and Identity Pro security behavior updates
**Who is affected**
- Applications using the **Account Pro** and **Identity Pro** modules for password reset/change and profile picture upload flows.
**What changed**
- Session revocation behavior was tightened after sensitive credential operations.
- Profile picture upload now enforces extension/size/magic-bytes validation by default.
- Forgot-password CAPTCHA and account-manage rate limiting capabilities were extended.
**What to do**
In most cases, no action is required on your part. However, please review the following considerations:
- Re-test your sign-in, password reset, and password change flows, especially if you have session continuity expectations.
- If your application needs additional image types/sizes, configure `AbpProfilePictureOptions` explicitly.
- Review account security settings (`UseCaptchaOnForgotPassword` and related rate-limit options) and adjust them to your UX/security requirements.

1
docs/en/release-info/migration-guides/index.md

@ -9,6 +9,7 @@
The following documents explain how to migrate your existing ABP applications. We write migration documents only if you need to take an action while upgrading your solution. Otherwise, you can easily upgrade your solution using the [abp update command](../upgrading.md).
- [10.x to 10.3](abp-10-3.md)
- [10.x to 10.2](abp-10-2.md)
- [10.0 to 10.1](abp-10-1.md)
- [9.x to 10.0](abp-10-0.md)

16
docs/en/release-info/release-notes.md

@ -14,9 +14,23 @@ Also see the following notes about ABP releases:
* [ABP Studio release notes](../studio/release-notes.md)
* [Change logs for ABP pro packages](https://abp.io/pro-releases)
## 10.3 (2026-04-01)
> This is currently an RC (release-candidate) and you can see the detailed **[blog post / announcement](https://abp.io/community/announcements/announcing-abp-10-3-release-candidate-hgnpr9jq)** for the v10.3 release.
* OpenIddict: `private_key_jwt` Client Authentication + `abp generate-jwks`
* Event Bus: String-Based Event Publishing with Dynamic Payload
* Background Jobs/Workers: String-Based Publishing with Dynamic Payload
* API Definition Endpoint: Descriptions and Documentation Support
* Entity Cache: New Batch APIs (`FindMany*` / `GetMany*`)
* Angular: User/Tenant Sharing and Tenant Switch Experience
* Angular: Upgrade to 21.2 + TypeScript 5.9
* Introducing the `Volo.Abp.LuckyPenny.AutoMapper` Provider
* Security Improvements (Account Pro Module)
## 10.2 (2026-02-24)
> This is currently an RC (release-candidate) and you can see the detailed **[blog post / announcement](https://abp.io/community/articles/announcing-abp-10-2-release-candidate-05zatjfq)** for the v10.2 release.
See the detailed **[blog post / announcement](https://abp.io/community/announcements/announcing-abp-10-2-stable-release-x47ytfww)** for the v10.2 release.
* Multi-Tenant Account Usage: Shared User Accounts
* Prevent Privilege Escalation: Assignment Restrictions for Roles and Permissions

23
docs/en/release-info/road-map.md

@ -1,7 +1,7 @@
```json
//[doc-seo]
{
"Description": "Explore the ABP Platform Road Map for insights on upcoming features, release schedules, and improvements in version 10.3, planned for April 2026."
"Description": "Explore the ABP Platform Road Map for insights on upcoming features, release schedules, and improvements in version 10.4, planned for May 2026."
}
```
@ -11,38 +11,36 @@ This document provides a road map, release schedule, and planned features for th
## Next Versions
### v10.3
### v10.4
The next version will be 10.3 and is planned to be released as a stable version in April 2026. We will be mostly working on the following topics:
After v10.3 reaches stable, the next planned version will be 10.4, which is scheduled to be released as a stable version in May 2026. We will be mostly working on the following topics:
* Framework
* Resource-Based Authorization Improvements
* Handle datetime/timezone in `AbpExtensibleDataGrid` Component
* Blazor UI: Moving from Blazorise to MudBlazor
* Token Verification Improvements (Refresh Token Support)
* Upgrading 3rd-party Dependencies
* Enhancements in the Core Points
* ABP Suite
* Improvements on the generated codes for nullability
* Improvements on Master-Detail Page Design (making it more compact)
* Improvements One-To-Many Scenarios
* File Upload Modal Enhancements
* ABP Studio
* Allow to Directly Create New Solutions with ABP's RC (Release Candidate) Versions
* Integrate AI Management Module with all solution templates and UIs (for Blazor & Angular UIs)
* Automate More Details on New Service Creation for a Microservice Solution
* Allow to Download ABP Samples from ABP Studio
* Support Multiple Concurrent Kubernetes Deployment/Integration Scenarios
* Improve the Module Installation Experience / Installation Guides
* Application Modules
* AI Management: Chat History & Visual Improvements on the playground
* CMS Kit: Enhancements for Some Features (Rating, Dynamic Widgets, FAQ and more...)
* AI Management: Chat History & Multi-Tenancy Features
* New Module: Chat with your data
* CMS Kit: CodeMirror v6 Compatibility Update
* Payment Module: Email Notification Improvements
* UI/UX Improvements on Existing Application Modules
* Updating Existing Tutorials & Documents (with Other UI & DB Options)
* Microservice Development
* Modular Monolith Development
* React Native template and implementation documents
## Backlog Items
@ -60,7 +58,6 @@ The ABP framework is [open source](https://github.com/abpframework/abp) and free
* [#2296](https://github.com/abpframework/abp/issues/2296) / Feature toggling infrastructure
* [#15932](https://github.com/abpframework/abp/issues/15932) / Introduce ABP Diagnostics Module
* [#16744](https://github.com/abpframework/abp/issues/16744) / State Management API
* [#17815](https://github.com/abpframework/abp/issues/17815) / Operation Rate Limiting
* [#119](https://github.com/abpframework/abp/issues/119) / REST API Versioning Improvements
* [#2087](https://github.com/abpframework/abp/issues/2087) / RavenDB Database Support

7
docs/en/studio/release-notes.md

@ -9,7 +9,12 @@
This document contains **brief release notes** for each ABP Studio release. Release notes only include **major features** and **visible enhancements**. Therefore, they don't include all the development done in the related version.
## 2.2.3 (2026-03-24) Latest
## 2.2.4 (2026-03-25) Latest
- Add `Template Create and Build` workflow
- Disable NuGetAudit in template common.props to prevent CLI deadlock during initial migration
## 2.2.3 (2026-03-24)
- Fix PostHog environment detection

2
docs/en/studio/version-mapping.md

@ -11,7 +11,7 @@ This document provides a general overview of the relationship between various ve
| **ABP Studio Version** | **ABP Version of Startup Template** |
|------------------------|---------------------------|
| 2.2.2 - 2.2.3 | 10.1.1 |
| 2.2.2 - 2.2.4 | 10.1.1 |
| 2.2.1 | 10.1.0 |
| 2.1.5 - 2.1.9 | 10.0.2 |
| 2.1.0 - 2.1.4 | 10.0.1 |

9
docs/en/tutorials/modular-crm/index.md

@ -7,17 +7,10 @@
# Modular Monolith Application Development Tutorial
```json
//[doc-params]
{
"UI": ["MVC", "NG"]
}
```
````json
//[doc-params]
{
"UI": ["MVC","BlazorWebApp"]
"UI": ["MVC", "BlazorWebApp", "NG"]
}
````

11
docs/en/tutorials/modular-crm/part-01.md

@ -7,17 +7,10 @@
# Creating the Initial Solution
```json
//[doc-params]
{
"UI": ["MVC", "NG"]
}
```
````json
//[doc-params]
{
"UI": ["MVC","BlazorWebApp"]
"UI": ["MVC", "BlazorWebApp", "NG"]
}
````
@ -42,7 +35,7 @@ In this first part of this tutorial, we will create a new ABP solution with modu
Follow the *[Get Started](../../get-started/single-layer-web-application.md)* guide to create a single layer web application with the following configuration:
* **Solution name**: `ModularCrm`
* **UI Framework**: {{if UI == "MVC"}}ASP.NET Core MVC / Razor Pages{{else if UI == "BlazorWebApp"}}Blazor WebApp{{end}}
* **UI Framework**: {{if UI == "MVC"}}ASP.NET Core MVC / Razor Pages{{else if UI == "BlazorWebApp"}}Blazor WebApp{{else if UI == "NG"}}Angular{{end}}
* **Database Provider**: Entity Framework Core
{{if UI == "NG"}}> **Note:** Angular users can continue with the Angular UI steps in the upcoming parts while following the same modularity flow.

9
docs/en/tutorials/modular-crm/part-02.md

@ -7,17 +7,10 @@
# Setting Up the Catalog Module
```json
//[doc-params]
{
"UI": ["MVC", "NG"]
}
```
````json
//[doc-params]
{
"UI": ["MVC","BlazorWebApp"]
"UI": ["MVC", "BlazorWebApp", "NG"]
}
````

11
docs/en/tutorials/modular-crm/part-03.md

@ -7,17 +7,10 @@
# Building the Catalog Module
```json
//[doc-params]
{
"UI": ["MVC", "NG"]
}
```
````json
//[doc-params]
{
"UI": ["MVC","BlazorWebApp"]
"UI": ["MVC", "BlazorWebApp", "NG"]
}
````
@ -639,4 +632,4 @@ yarn start
## Summary
In this part of the tutorial, you've built the functionality inside the _Catalog_ module, which was created in the [previous part](part-02.md), and created a basic {{if UI == "MVC"}}MVC{{else if UI == "NG"}}Angular{{end}} UI to list products. In the next part, you will create a new _Ordering_ module and install it into the main application.
In this part of the tutorial, you've built the functionality inside the _Catalog_ module, which was created in the [previous part](part-02.md), and created a basic {{if UI == "MVC"}}MVC{{else if UI == "BlazorWebApp"}}Blazor WebApp{{else if UI == "NG"}}Angular{{end}} UI to list products. In the next part, you will create a new _Ordering_ module and install it into the main application.

15
docs/en/tutorials/modular-crm/part-04.md

@ -7,17 +7,10 @@
# Creating the Initial Ordering Module
```json
//[doc-params]
{
"UI": ["MVC", "NG"]
}
```
````json
//[doc-params]
{
"UI": ["MVC","BlazorWebApp"]
"UI": ["MVC", "BlazorWebApp", "NG"]
}
````
@ -55,11 +48,13 @@ Set `ModularCrm.Ordering` as the *Module name*, leave the *Output folder* as is
{{if UI == "MVC"}}
![abp-studio-add-new-standard-module-ui-dialog](images/abp-studio-add-new-standard-module-ui-dialog.png)
You can choose the type of UI you want to support in your module or select *No UI* if you don't need a user interface. In this example, we'll select the *MVC* option and click *Next*.
{{else if UI == "BlazorWebApp"}}
![abp-studio-add-new-standard-module-ui-dialog](images/abp-studio-add-new-standard-module-ui-dialog-blazor-webapp.png)
{{end}}
You can choose the type of UI you want to support in your module or select *No UI* if you don't need a user interface. In this example, we'll select the {{if UI == "MVC"}}*MVC*{{else if UI == "BlazorWebApp"}}*Blazor WebApp*{{end}} option and click *Next*.
You can choose the type of UI you want to support in your module or select *No UI* if you don't need a user interface. In this example, we'll select the *Blazor WebApp* option and click *Next*.
{{else if UI == "NG"}}

11
docs/en/tutorials/modular-crm/part-05.md

@ -7,17 +7,10 @@
# Building the Ordering Module
```json
//[doc-params]
{
"UI": ["MVC", "NG"]
}
```
````json
//[doc-params]
{
"UI": ["MVC","BlazorWebApp"]
"UI": ["MVC", "BlazorWebApp", "NG"]
}
````
@ -703,4 +696,4 @@ Navigate to the *Ordering* page and verify that the orders are listed. At this s
## Summary
In this part of the *Modular CRM* tutorial, you've built the functionality inside the Ordering module you created in the [previous part](part-04.md) and created a basic {{if UI == "MVC"}}MVC{{else if UI == "NG"}}Angular{{end}} UI to list orders. In the [next part](part-06.md), you will work on establishing communication between the Orders module and the Catalog module.
In this part of the *Modular CRM* tutorial, you've built the functionality inside the Ordering module you created in the [previous part](part-04.md) and created a basic {{if UI == "MVC"}}MVC{{else if UI == "BlazorWebApp"}}Blazor WebApp{{else if UI == "NG"}}Angular{{end}} UI to list orders. In the [next part](part-06.md), you will work on establishing communication between the Orders module and the Catalog module.

19
docs/en/tutorials/modular-crm/part-06.md

@ -7,17 +7,10 @@
# Integrating the Modules: Implementing Integration Services
```json
//[doc-params]
{
"UI": ["MVC", "NG"]
}
```
````json
//[doc-params]
{
"UI": ["MVC","BlazorWebApp"]
"UI": ["MVC", "BlazorWebApp", "NG"]
}
````
@ -293,6 +286,12 @@ Open the `Index.cshtml` file, and change the `@order.ProductId` part to `@order.
</abp-card>
````
That's all. Now, you can graph build the main application and run it in ABP Studio to see the result:
![abp-studio-browser-list-of-orders-with-product-name](images/abp-studio-browser-list-of-orders-with-product-name.png)
As you can see, we can see the product names instead of product IDs.
{{else if UI == "BlazorWebApp"}}
Open the `Index.razor` file, and change the `@order.ProductId` part to `@order.ProductName` to write the product name instead of the product ID. The final `Index.razor` content should be the following:
@ -331,8 +330,6 @@ Open the `Index.razor` file, and change the `@order.ProductId` part to `@order.P
}
````
{{end}}
That's all. Now, you can graph build the main application and run it in ABP Studio to see the result:
![abp-studio-browser-list-of-orders-with-product-name](images/abp-studio-browser-list-of-orders-with-product-name.png)
@ -361,4 +358,4 @@ Then run the Angular app (`yarn start` in the `angular` folder), navigate to the
## Conclusion
In this part of the tutorial, you created and consumed an integration service between modules, then reflected the product name on the {{if UI == "MVC"}}MVC{{else if UI == "NG"}}Angular{{end}} UI. In the [next part](part-07.md), we will explore event based messaging between the modules.
In this part of the tutorial, you created and consumed an integration service between modules, then reflected the product name on the {{if UI == "MVC"}}MVC{{else if UI == "BlazorWebApp"}}Blazor WebApp{{else if UI == "NG"}}Angular{{end}} UI. In the [next part](part-07.md), we will explore event based messaging between the modules.

9
docs/en/tutorials/modular-crm/part-07.md

@ -7,17 +7,10 @@
# Integrating the Modules: Communication via Messages (Events)
```json
//[doc-params]
{
"UI": ["MVC", "NG"]
}
```
````json
//[doc-params]
{
"UI": ["MVC","BlazorWebApp"]
"UI": ["MVC", "BlazorWebApp", "NG"]
}
````

13
docs/en/tutorials/modular-crm/part-08.md

@ -5,22 +5,15 @@
}
```
```json
//[doc-params]
{
"UI": ["MVC", "NG"]
}
```
# Integrating the Modules: Joining the Products and Orders Data
````json
//[doc-params]
{
"UI": ["MVC","BlazorWebApp"]
"UI": ["MVC", "BlazorWebApp", "NG"]
}
````
# Integrating the Modules: Joining the Products and Orders Data
````json
//[doc-nav]
{

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

@ -0,0 +1,63 @@
using System;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using Volo.Abp.AspNetCore.Mvc.Client;
using Volo.Abp.DependencyInjection;
using Volo.Abp.UI.Navigation;
namespace Volo.Abp.AspNetCore.Components.WebAssembly.Theming;
/// <summary>
/// Prepends the culture route prefix to menu item URLs in Blazor WebAssembly when route-based culture is enabled.
/// </summary>
public class AbpWasmCultureMenuItemUrlProvider : IMenuItemUrlProvider, ITransientDependency
{
protected ICachedApplicationConfigurationClient ConfigurationClient { get; }
protected IMenuItemCulturePrefixHelper MenuItemCulturePrefixHelper { get; }
public AbpWasmCultureMenuItemUrlProvider(
ICachedApplicationConfigurationClient configurationClient,
IMenuItemCulturePrefixHelper menuItemCulturePrefixHelper)
{
ConfigurationClient = configurationClient;
MenuItemCulturePrefixHelper = menuItemCulturePrefixHelper;
}
public virtual async Task HandleAsync(MenuItemUrlProviderContext context)
{
if (!OperatingSystem.IsBrowser())
{
return;
}
var config = await ConfigurationClient.GetAsync();
if (!config.Localization.UseRouteBasedCulture)
{
return;
}
var culture = GetCulture(config);
if (string.IsNullOrEmpty(culture))
{
return;
}
await MenuItemCulturePrefixHelper.PrependCulturePrefixAsync(context.Menu, "/" + culture);
}
protected virtual string? GetCulture(Mvc.ApplicationConfigurations.ApplicationConfigurationDto config)
{
var currentCulture = CultureInfo.CurrentCulture.Name;
var languages = config.Localization.Languages;
if (languages.Count == 0)
{
return null;
}
var isKnownCulture = languages
.Any(l => string.Equals(l.CultureName, currentCulture, StringComparison.OrdinalIgnoreCase));
return isKnownCulture ? currentCulture : null;
}
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

17
framework/src/Volo.Abp.Autofac/Autofac/Extensions/DependencyInjection/AutofacRegistration.cs

@ -119,7 +119,6 @@ public static class AutofacRegistration
.SingleInstance();
// Shims for keyed service compatibility.
builder.RegisterSource<AnyKeyRegistrationSource>();
builder.ComponentRegistryBuilder.Registered += AddFromKeyedServiceParameterMiddleware;
Register(builder, services, lifetimeScopeTagForSingletons);
@ -212,11 +211,15 @@ public static class AutofacRegistration
this IRegistrationBuilder<object, TActivatorData, TRegistrationStyle> registrationBuilder,
ServiceDescriptor descriptor)
{
// If it's keyed, the service key won't be null. A null key results in it _not_ being a keyed service.
if (descriptor.IsKeyedService)
{
var key = descriptor.ServiceKey!;
if (key.Equals(Microsoft.Extensions.DependencyInjection.KeyedService.AnyKey))
{
key = Autofac.Core.KeyedService.AnyKey;
}
// If it's keyed, the service key won't be null. A null key results in it _not_ being a keyed service.
registrationBuilder.Keyed(key, descriptor.ServiceType);
}
else
@ -334,9 +337,7 @@ public static class AutofacRegistration
var serviceProvider = context.Resolve<IServiceProvider>();
var keyedService = (Autofac.Core.KeyedService)requestContext.Service;
var key = keyedService.ServiceKey;
var key = requestContext.Parameters.KeyedServiceKey<object>();
return descriptor.KeyedImplementationFactory(serviceProvider, key);
})
@ -349,8 +350,7 @@ public static class AutofacRegistration
continue;
}
if (!descriptor.IsKeyedService && descriptor.ImplementationFactory != null)
else if (!descriptor.IsKeyedService && descriptor.ImplementationFactory != null)
{
var registration = RegistrationBuilder.ForDelegate(descriptor.ServiceType, (context, parameters) =>
{
@ -371,7 +371,8 @@ public static class AutofacRegistration
builder
.RegisterInstance(descriptor.NormalizedImplementationInstance()!)
.ConfigureServiceType(descriptor)
.ConfigureLifecycle(descriptor.Lifetime, null);
.ConfigureLifecycle(descriptor.Lifetime, null)
.ExternallyOwned();
}
}
}

16
framework/src/Volo.Abp.Cli.Core/Volo/Abp/Cli/Utils/CmdHelper.cs

@ -1,7 +1,8 @@
using System;
using System;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using Microsoft.Extensions.Options;
using Volo.Abp.DependencyInjection;
@ -137,14 +138,11 @@ public class CmdHelper : ICmdHelper, ITransientDependency
process.Start();
using (var standardOutput = process.StandardOutput)
{
using (var standardError = process.StandardError)
{
output = standardOutput.ReadToEnd();
output += standardError.ReadToEnd();
}
}
var outputTask = process.StandardOutput.ReadToEndAsync();
var errorTask = process.StandardError.ReadToEndAsync();
var results = Task.WhenAll(outputTask, errorTask).GetAwaiter().GetResult();
output = results[0] + results[1];
process.WaitForExit();

12
framework/src/Volo.Abp.TextTemplating.Scriban/Volo/Abp/TextTemplating/Scriban/ScribanTemplateLocalizer.cs

@ -18,16 +18,16 @@ public class ScribanTemplateLocalizer : IScriptCustomFunction
_localizer = localizer;
}
public object Invoke(TemplateContext context, ScriptNode callerContext, ScriptArray arguments,
ScriptBlockStatement blockStatement)
public object? Invoke(TemplateContext context, ScriptNode? callerContext, ScriptArray arguments,
ScriptBlockStatement? blockStatement)
{
return GetString(arguments);
}
public ValueTask<object> InvokeAsync(TemplateContext context, ScriptNode callerContext, ScriptArray arguments,
ScriptBlockStatement blockStatement)
public ValueTask<object?> InvokeAsync(TemplateContext context, ScriptNode? callerContext, ScriptArray arguments,
ScriptBlockStatement? blockStatement)
{
return new ValueTask<object>(GetString(arguments));
return new ValueTask<object?>(GetString(arguments));
}
private string GetString(ScriptArray arguments)
@ -43,7 +43,7 @@ public class ScribanTemplateLocalizer : IScriptCustomFunction
return string.Empty;
}
var args = arguments.Skip(1).Where(x => x != null && !x.ToString().IsNullOrWhiteSpace()).ToArray();
var args = arguments.Skip(1).Where(x => x != null && !x.ToString().IsNullOrWhiteSpace()).Cast<object>().ToArray();
return args.Any() ? _localizer[name.ToString()!, args] : _localizer[name.ToString()!];
}

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

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

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

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

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

@ -0,0 +1,33 @@
using System.Threading.Tasks;
using Volo.Abp.DependencyInjection;
namespace Volo.Abp.UI.Navigation;
public class MenuItemCulturePrefixHelper : IMenuItemCulturePrefixHelper, ITransientDependency
{
public virtual Task PrependCulturePrefixAsync(IHasMenuItems menuWithItems, string prefix)
{
PrependCulturePrefix(menuWithItems, prefix);
return Task.CompletedTask;
}
protected virtual void PrependCulturePrefix(IHasMenuItems menuWithItems, string prefix)
{
foreach (var item in menuWithItems.Items)
{
if (item.Url != null)
{
if (item.Url.StartsWith("~/"))
{
item.Url = "~" + prefix + item.Url.Substring(1);
}
else if (item.Url.StartsWith("/"))
{
item.Url = prefix + item.Url;
}
}
PrependCulturePrefix(item, prefix);
}
}
}

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

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

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

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

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

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

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

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

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

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

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

@ -0,0 +1,280 @@
using System;
using System.Globalization;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.RequestLocalization;
using Microsoft.AspNetCore.Routing;
using Shouldly;
using MsOptions = Microsoft.Extensions.Options.Options;
using Volo.Abp.Localization;
using Volo.Abp.UI.Navigation;
using Xunit;
namespace Volo.Abp.AspNetCore.Mvc.Localization;
public class AbpCultureMenuItemUrlProvider_Tests
{
[Fact]
public async Task Should_Not_Modify_Urls_When_RouteBasedCulture_Is_Disabled()
{
var provider = CreateProvider(useRouteBasedCulture: false, cultureName: "zh-Hans");
var menu = CreateMenuWithItems("/home", "/about");
await provider.HandleAsync(new MenuItemUrlProviderContext(menu));
menu.Items[0].Url.ShouldBe("/home");
menu.Items[1].Url.ShouldBe("/about");
}
[Fact]
public async Task Should_Prepend_Culture_Prefix_When_Route_Has_Culture()
{
var provider = CreateProvider(useRouteBasedCulture: true, cultureName: "zh-Hans");
var menu = CreateMenuWithItems("/home", "/about");
await provider.HandleAsync(new MenuItemUrlProviderContext(menu));
menu.Items[0].Url.ShouldBe("/zh-Hans/home");
menu.Items[1].Url.ShouldBe("/zh-Hans/about");
}
[Fact]
public async Task Should_Not_Add_Prefix_For_Mvc_Request_Without_Culture()
{
// MVC request to /about (no culture, no HasRouteCulture cookie).
var httpContext = new DefaultHttpContext();
httpContext.Request.RouteValues["controller"] = "Home";
httpContext.Request.RouteValues["action"] = "About";
var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext };
var localizationOptions = MsOptions.Create(
new AbpRequestLocalizationOptions { UseRouteBasedCulture = true });
var abpLocOptions = new AbpLocalizationOptions();
abpLocOptions.Languages.Add(new LanguageInfo("en"));
abpLocOptions.Languages.Add(new LanguageInfo("zh-Hans"));
var provider = new AbpCultureMenuItemUrlProvider(
httpContextAccessor, localizationOptions, MsOptions.Create(abpLocOptions), new MenuItemCulturePrefixHelper());
var menu = CreateMenuWithItems("/home", "/about");
var previousCulture = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = new CultureInfo("zh-Hans");
await provider.HandleAsync(new MenuItemUrlProviderContext(menu));
}
finally
{
CultureInfo.CurrentCulture = previousCulture;
}
menu.Items[0].Url.ShouldBe("/home");
menu.Items[1].Url.ShouldBe("/about");
}
[Fact]
public async Task Should_Fallback_To_CurrentCulture_In_Blazor_Circuit()
{
// Blazor Server interactive circuit: HttpContext exists (SignalR) but has
// no route culture. Cookie was set during SSR indicating route culture was used.
var httpContext = new DefaultHttpContext();
httpContext.Request.Headers["Cookie"] = $"{AbpRequestCultureCookieHelper.HasRouteCultureCookieName}=1";
var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext };
var localizationOptions = MsOptions.Create(
new AbpRequestLocalizationOptions { UseRouteBasedCulture = true });
var abpLocOptions = new AbpLocalizationOptions();
abpLocOptions.Languages.Add(new LanguageInfo("en"));
abpLocOptions.Languages.Add(new LanguageInfo("zh-Hans"));
var provider = new AbpCultureMenuItemUrlProvider(
httpContextAccessor, localizationOptions, MsOptions.Create(abpLocOptions), new MenuItemCulturePrefixHelper());
var menu = CreateMenuWithItems("/home", "/about");
var previousCulture = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = new CultureInfo("zh-Hans");
await provider.HandleAsync(new MenuItemUrlProviderContext(menu));
}
finally
{
CultureInfo.CurrentCulture = previousCulture;
}
menu.Items[0].Url.ShouldBe("/zh-Hans/home");
menu.Items[1].Url.ShouldBe("/zh-Hans/about");
}
[Fact]
public async Task Should_Use_CurrentCulture_Fallback_When_No_HttpContext()
{
// Simulates Blazor interactive circuit: no HttpContext, but CurrentCulture is set.
// CurrentCulture (not CurrentUICulture) is used because {culture} route segments
// represent the culture, not the UI culture.
var provider = CreateProviderWithoutHttpContext(
useRouteBasedCulture: true,
knownLanguages: new[] { "en", "zh-Hans", "tr" });
var menu = CreateMenuWithItems("/home", "/about");
var previousCulture = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = new CultureInfo("zh-Hans");
await provider.HandleAsync(new MenuItemUrlProviderContext(menu));
}
finally
{
CultureInfo.CurrentCulture = previousCulture;
}
menu.Items[0].Url.ShouldBe("/zh-Hans/home");
menu.Items[1].Url.ShouldBe("/zh-Hans/about");
}
[Fact]
public async Task Should_Not_Modify_Urls_When_No_HttpContext_And_Unknown_Culture()
{
// Blazor interactive circuit with a culture that is not in the known languages list
var provider = CreateProviderWithoutHttpContext(
useRouteBasedCulture: true,
knownLanguages: new[] { "en", "tr" });
var menu = CreateMenuWithItems("/home", "/about");
var previousCulture = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = new CultureInfo("fr");
await provider.HandleAsync(new MenuItemUrlProviderContext(menu));
}
finally
{
CultureInfo.CurrentCulture = previousCulture;
}
menu.Items[0].Url.ShouldBe("/home");
menu.Items[1].Url.ShouldBe("/about");
}
[Fact]
public async Task Should_Prepend_Prefix_Recursively_For_Nested_Items()
{
var provider = CreateProvider(useRouteBasedCulture: true, cultureName: "tr");
var menu = new ApplicationMenu("TestMenu");
var parent = new ApplicationMenuItem("Parent", "Parent", url: "/parent");
var child = new ApplicationMenuItem("Child", "Child", url: "/child");
var grandChild = new ApplicationMenuItem("GrandChild", "GrandChild", url: "/grandchild");
child.AddItem(grandChild);
parent.AddItem(child);
menu.AddItem(parent);
await provider.HandleAsync(new MenuItemUrlProviderContext(menu));
parent.Url.ShouldBe("/tr/parent");
child.Url.ShouldBe("/tr/child");
grandChild.Url.ShouldBe("/tr/grandchild");
}
[Fact]
public async Task Should_Handle_Tilde_Slash_Urls()
{
// ~/identity/users is the pattern used by ABP module menu contributors (e.g. Identity)
var provider = CreateProvider(useRouteBasedCulture: true, cultureName: "zh-Hans");
var menu = new ApplicationMenu("TestMenu");
menu.AddItem(new ApplicationMenuItem("Users", "Users", url: "~/identity/users"));
menu.AddItem(new ApplicationMenuItem("Roles", "Roles", url: "~/identity/roles"));
await provider.HandleAsync(new MenuItemUrlProviderContext(menu));
// ~/identity/users → ~/zh-Hans/identity/users
// Blazor theme strips "~/" via TrimStart('/', '~') → "zh-Hans/identity/users"
// With <base href="/"> resolves to /zh-Hans/identity/users
menu.Items[0].Url.ShouldBe("~/zh-Hans/identity/users");
menu.Items[1].Url.ShouldBe("~/zh-Hans/identity/roles");
}
[Fact]
public async Task Should_Not_Modify_External_Urls()
{
var provider = CreateProvider(useRouteBasedCulture: true, cultureName: "zh-Hans");
var menu = new ApplicationMenu("TestMenu");
menu.AddItem(new ApplicationMenuItem("External", "External", url: "https://example.com/page"));
menu.AddItem(new ApplicationMenuItem("Relative", "Relative", url: "page"));
menu.AddItem(new ApplicationMenuItem("Local", "Local", url: "/local"));
await provider.HandleAsync(new MenuItemUrlProviderContext(menu));
// External and relative URLs should not be modified
menu.Items[0].Url.ShouldBe("https://example.com/page");
menu.Items[1].Url.ShouldBe("page");
// Local URL should be prefixed
menu.Items[2].Url.ShouldBe("/zh-Hans/local");
}
[Fact]
public async Task Should_Not_Throw_When_Url_Is_Null()
{
var provider = CreateProvider(useRouteBasedCulture: true, cultureName: "tr");
var menu = new ApplicationMenu("TestMenu");
menu.AddItem(new ApplicationMenuItem("NoUrl", "No URL", url: null));
menu.AddItem(new ApplicationMenuItem("WithUrl", "With URL", url: "/page"));
await provider.HandleAsync(new MenuItemUrlProviderContext(menu));
// Null URL should remain null
menu.Items[0].Url.ShouldBeNull();
// Normal URL should be prefixed
menu.Items[1].Url.ShouldBe("/tr/page");
}
private static AbpCultureMenuItemUrlProvider CreateProvider(
bool useRouteBasedCulture,
string? cultureName)
{
var httpContext = new DefaultHttpContext();
if (cultureName != null)
{
httpContext.Request.RouteValues["culture"] = cultureName;
}
var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext };
var localizationOptions = MsOptions.Create(
new AbpRequestLocalizationOptions { UseRouteBasedCulture = useRouteBasedCulture });
var abpLocalizationOptions = MsOptions.Create(new AbpLocalizationOptions());
return new AbpCultureMenuItemUrlProvider(
httpContextAccessor, localizationOptions, abpLocalizationOptions, new MenuItemCulturePrefixHelper());
}
private static AbpCultureMenuItemUrlProvider CreateProviderWithoutHttpContext(
bool useRouteBasedCulture,
string[] knownLanguages)
{
var httpContextAccessor = new HttpContextAccessor { HttpContext = null };
var localizationOptions = MsOptions.Create(
new AbpRequestLocalizationOptions { UseRouteBasedCulture = useRouteBasedCulture });
var abpLocOptions = new AbpLocalizationOptions();
foreach (var lang in knownLanguages)
{
abpLocOptions.Languages.Add(new LanguageInfo(lang));
}
return new AbpCultureMenuItemUrlProvider(
httpContextAccessor, localizationOptions, MsOptions.Create(abpLocOptions), new MenuItemCulturePrefixHelper());
}
private static ApplicationMenu CreateMenuWithItems(params string[] urls)
{
var menu = new ApplicationMenu("TestMenu");
for (var i = 0; i < urls.Length; i++)
{
menu.AddItem(new ApplicationMenuItem($"Item{i}", $"Item {i}", url: urls[i]));
}
return menu;
}
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

157
framework/test/Volo.Abp.Autofac.Tests/Volo/Abp/Autofac/AutofacRegistration_Tests.cs

@ -0,0 +1,157 @@
using System;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using Volo.Abp.Modularity;
using Volo.Abp.Testing;
using Xunit;
namespace Volo.Abp.Autofac;
public class AutofacRegistration_Tests : AbpIntegratedTest<AutofacRegistration_Tests.TestModule>
{
protected override void SetAbpApplicationCreationOptions(AbpApplicationCreationOptions options)
{
options.UseAutofac();
}
[Fact]
public void Should_Resolve_AnyKey_Keyed_Service_With_Any_Key()
{
// AnyKey registration should be resolvable with any key value.
var serviceWithKeyA = GetRequiredKeyedService<IAnyKeyService>("keyA");
var serviceWithKeyB = GetRequiredKeyedService<IAnyKeyService>("keyB");
var serviceWithKeyC = GetRequiredKeyedService<IAnyKeyService>(42);
serviceWithKeyA.ShouldNotBeNull();
serviceWithKeyB.ShouldNotBeNull();
serviceWithKeyC.ShouldNotBeNull();
serviceWithKeyA.ShouldBeOfType<AnyKeyServiceImpl>();
serviceWithKeyB.ShouldBeOfType<AnyKeyServiceImpl>();
serviceWithKeyC.ShouldBeOfType<AnyKeyServiceImpl>();
}
[Fact]
public void Should_Pass_Correct_Key_To_Keyed_Factory()
{
var serviceA = GetRequiredKeyedService<IKeyedFactoryService>("alpha");
var serviceB = GetRequiredKeyedService<IKeyedFactoryService>("beta");
serviceA.Key.ShouldBe("alpha");
serviceB.Key.ShouldBe("beta");
}
[Fact]
public void Should_Not_Dispose_Instance_Registration_When_Scope_Disposed()
{
// Resolve the pre-registered singleton instance.
var instance = GetRequiredKeyedService<IDisposableInstance>("instance");
instance.ShouldNotBeNull();
instance.IsDisposed.ShouldBeFalse();
// The same instance should be returned from a child scope.
using (var scope = ServiceProvider.CreateScope())
{
var scopedInstance = scope.ServiceProvider.GetRequiredKeyedService<IDisposableInstance>("instance");
scopedInstance.ShouldBeSameAs(instance);
}
// After the scope is disposed, the singleton instance should still be alive.
instance.IsDisposed.ShouldBeFalse();
// It should also be the same static instance registered in the module.
instance.ShouldBeSameAs(TestModule.DisposableInstanceForTest);
}
[Fact]
public void Should_Resolve_Standard_Keyed_Services()
{
var big = GetRequiredKeyedService<ITypedCache>("big");
var small = GetRequiredKeyedService<ITypedCache>("small");
big.ShouldBeOfType<BigTypedCache>();
small.ShouldBeOfType<SmallTypedCache>();
big.Get("test").ShouldBe("big:test");
small.Get("test").ShouldBe("small:test");
}
[DependsOn(typeof(AbpAutofacModule))]
public class TestModule : AbpModule
{
public static DisposableInstance DisposableInstanceForTest { get; } = new();
public override void ConfigureServices(ServiceConfigurationContext context)
{
// AnyKey registration: this service can be resolved with any key.
context.Services.AddKeyedTransient<IAnyKeyService, AnyKeyServiceImpl>(
Microsoft.Extensions.DependencyInjection.KeyedService.AnyKey);
// Keyed factory registration: the factory receives the actual key used for resolution.
context.Services.Add(ServiceDescriptor.KeyedTransient<IKeyedFactoryService>(
Microsoft.Extensions.DependencyInjection.KeyedService.AnyKey,
(sp, key) => new KeyedFactoryServiceImpl(key)));
// Instance registration with keyed service (ExternallyOwned should prevent Autofac from disposing it).
context.Services.AddKeyedSingleton<IDisposableInstance>("instance", DisposableInstanceForTest);
// Standard keyed type registrations.
context.Services.AddKeyedTransient<ITypedCache, BigTypedCache>("big");
context.Services.AddKeyedTransient<ITypedCache, SmallTypedCache>("small");
}
}
public interface IAnyKeyService
{
}
public class AnyKeyServiceImpl : IAnyKeyService
{
}
public interface IKeyedFactoryService
{
object Key { get; }
}
public class KeyedFactoryServiceImpl : IKeyedFactoryService
{
public object Key { get; }
public KeyedFactoryServiceImpl(object key)
{
Key = key;
}
}
public interface IDisposableInstance
{
bool IsDisposed { get; }
}
public class DisposableInstance : IDisposableInstance, IDisposable
{
public bool IsDisposed { get; private set; }
public void Dispose()
{
IsDisposed = true;
}
}
public interface ITypedCache
{
string Get(string key);
}
public class BigTypedCache : ITypedCache
{
public string Get(string key) => $"big:{key}";
}
public class SmallTypedCache : ITypedCache
{
public string Get(string key) => $"small:{key}";
}
}

47
framework/test/Volo.Abp.Cli.Core.Tests/Volo/Abp/Cli/Utils/CmdHelper_Tests.cs

@ -0,0 +1,47 @@
using System;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using Shouldly;
using Volo.Abp.Cli.Utils;
using Xunit;
namespace Volo.Abp.Cli.Utils;
public class CmdHelper_Tests : AbpCliTestBase
{
private readonly ICmdHelper _cmdHelper;
public CmdHelper_Tests()
{
_cmdHelper = GetRequiredService<ICmdHelper>();
}
[Fact]
public async Task RunCmdAndGetOutput_Should_Not_Deadlock_With_Large_Stdout_And_Stderr()
{
// Reproduces the deadlock bug where sequential ReadToEnd() on stdout then stderr
// would block indefinitely when both pipe buffers (~64 KB on Linux/macOS, ~4 KB on
// Windows) filled up simultaneously. The process was blocked writing to stderr while
// the caller was blocked waiting for stdout to close — a classic pipe deadlock.
//
// The fix reads both streams concurrently via ReadToEndAsync + Task.WhenAll, which
// drains both pipes at the same time and avoids the deadlock.
var command = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
? @"for /L %i in (1,1,3000) do @(echo stdout-line-%i & echo stderr-line-%i 1>&2)"
: "for i in $(seq 1 5000); do echo stdout-line-$i; echo stderr-line-$i >&2; done";
string output = null;
var cmdTask = Task.Run(() => output = _cmdHelper.RunCmdAndGetOutput(command));
var completed = await Task.WhenAny(cmdTask, Task.Delay(TimeSpan.FromSeconds(10)));
// The original sequential code deadlocked here; 10 s is a generous upper bound.
(completed == cmdTask).ShouldBeTrue(
"RunCmdAndGetOutput should not deadlock when both stdout and stderr produce large output");
await cmdTask;
output.ShouldNotBeNullOrWhiteSpace();
output.ShouldContain("stdout-line-");
output.ShouldContain("stderr-line-");
}
}

110
framework/test/Volo.Abp.EventBus.Tests/Volo/Abp/EventBus/Distributed/LocalDistributedEventBus_Test.cs

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using Volo.Abp.Domain.Entities.Events.Distributed;
using Volo.Abp.EventBus.Local;
@ -372,6 +373,57 @@ public class LocalDistributedEventBus_Test : LocalDistributedEventBusTestBase
}
}
[Fact]
public async Task IocEventHandlerFactory_Should_Create_New_Scope_And_Dispose_Handler_Per_Event()
{
var handleCount = 0;
var disposeCount = 0;
var eventName = "IocEvent-" + Guid.NewGuid().ToString("N");
var services = new ServiceCollection();
services.AddSingleton<ITestCounterService>(
new TestCounterService(() => handleCount++, () => disposeCount++));
services.AddTransient<DynamicIocEventHandlerWithCounter>();
using var provider = services.BuildServiceProvider();
var scopeFactory = provider.GetRequiredService<IServiceScopeFactory>();
using var subscription = DistributedEventBus.Subscribe(
eventName,
new IocEventHandlerFactory(scopeFactory, typeof(DynamicIocEventHandlerWithCounter)));
await DistributedEventBus.PublishAsync(eventName, new { Value = 1 });
await DistributedEventBus.PublishAsync(eventName, new { Value = 2 });
await DistributedEventBus.PublishAsync(eventName, new { Value = 3 });
// Handler is invoked exactly once per event.
Assert.Equal(3, handleCount);
// Handler is disposed at least once per event (the scope is always cleaned up).
Assert.True(disposeCount >= handleCount);
}
[Fact]
public async Task IocEventHandlerFactory_Should_Resolve_DI_Services_In_Handler_Constructor()
{
var callCount = 0;
var eventName = "IocEvent-" + Guid.NewGuid().ToString("N");
var services = new ServiceCollection();
services.AddSingleton<ITestCounterService>(new TestCounterService(() => callCount++));
services.AddTransient<DynamicIocEventHandlerWithService>();
using var provider = services.BuildServiceProvider();
var scopeFactory = provider.GetRequiredService<IServiceScopeFactory>();
using var subscription = DistributedEventBus.Subscribe(
eventName,
new IocEventHandlerFactory(scopeFactory, typeof(DynamicIocEventHandlerWithService)));
await DistributedEventBus.PublishAsync(eventName, new { Value = 1 });
await DistributedEventBus.PublishAsync(eventName, new { Value = 2 });
// The handler resolved ITestCounterService via constructor injection
Assert.Equal(2, callCount);
}
class TestDynamicDistributedEventHandler : IDistributedEventHandler<DynamicEventData>
{
private readonly Action _onHandle;
@ -387,4 +439,62 @@ public class LocalDistributedEventBus_Test : LocalDistributedEventBusTestBase
return Task.CompletedTask;
}
}
interface ITestCounterService
{
void OnHandle();
void OnDispose();
}
class TestCounterService : ITestCounterService
{
private readonly Action _onHandle;
private readonly Action? _onDispose;
public TestCounterService(Action onHandle, Action? onDispose = null)
{
_onHandle = onHandle;
_onDispose = onDispose;
}
public void OnHandle() => _onHandle();
public void OnDispose() => _onDispose?.Invoke();
}
class DynamicIocEventHandlerWithCounter : IDistributedEventHandler<DynamicEventData>, IDisposable
{
private readonly ITestCounterService _counterService;
public DynamicIocEventHandlerWithCounter(ITestCounterService counterService)
{
_counterService = counterService;
}
public Task HandleEventAsync(DynamicEventData eventData)
{
_counterService.OnHandle();
return Task.CompletedTask;
}
public void Dispose()
{
_counterService.OnDispose();
}
}
class DynamicIocEventHandlerWithService : IDistributedEventHandler<DynamicEventData>
{
private readonly ITestCounterService _counterService;
public DynamicIocEventHandlerWithService(ITestCounterService counterService)
{
_counterService = counterService;
}
public Task HandleEventAsync(DynamicEventData eventData)
{
_counterService.OnHandle();
return Task.CompletedTask;
}
}
}

9
latest-versions.json

@ -1,4 +1,13 @@
[
{
"version": "10.2.0",
"releaseDate": "",
"type": "stable",
"message": "",
"leptonx": {
"version": "5.2.0"
}
},
{
"version": "10.1.1",
"releaseDate": "",

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

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

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

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

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

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

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

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

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

@ -2,6 +2,8 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Routing;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.RequestLocalization;
using Volo.Abp.UI.Navigation;
namespace Volo.Abp.AspNetCore.Components.Server.BasicTheme.Themes.Basic;
@ -11,6 +13,9 @@ public partial class LoginDisplay : IDisposable
[Inject]
protected IMenuManager MenuManager { get; set; }
[Inject]
protected IHttpContextAccessor HttpContextAccessor { get; set; }
protected ApplicationMenu Menu { get; set; }
protected override async Task OnInitializedAsync()
@ -20,6 +25,12 @@ public partial class LoginDisplay : IDisposable
Navigation.LocationChanged += OnLocationChanged;
}
protected string GetLoginUrl()
{
var culture = AbpRequestCultureCookieHelper.GetRouteCulture(HttpContextAccessor.HttpContext);
return string.IsNullOrEmpty(culture) ? "Account/Login" : $"{culture}/Account/Login";
}
protected virtual void OnLocationChanged(object sender, LocationChangedEventArgs e)
{
InvokeAsync(StateHasChanged);

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

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

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

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

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

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

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

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

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

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

6
modules/basic-theme/test/Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.Demo/package.json

@ -3,8 +3,8 @@
"name": "asp.net",
"private": true,
"dependencies": {
"@abp/aspnetcore.mvc.ui.theme.shared": "~10.2.0-rc.3",
"@abp/prismjs": "~10.2.0-rc.3",
"@abp/highlight.js": "~10.2.0-rc.3"
"@abp/aspnetcore.mvc.ui.theme.shared": "~10.3.0-rc.1",
"@abp/prismjs": "~10.3.0-rc.1",
"@abp/highlight.js": "~10.3.0-rc.1"
}
}

266
modules/basic-theme/test/Volo.Abp.AspNetCore.Mvc.UI.Bootstrap.Demo/yarn.lock

@ -2,204 +2,204 @@
# yarn lockfile v1
"@abp/aspnetcore.mvc.ui.theme.shared@~10.2.0-rc.3":
version "10.2.0-rc.3"
resolved "https://registry.yarnpkg.com/@abp/aspnetcore.mvc.ui.theme.shared/-/aspnetcore.mvc.ui.theme.shared-10.2.0-rc.3.tgz#df0ef67776ac194d535c62c27909a8e4b9eb7d06"
integrity sha512-6DlnJDnO2a+BvV3/laX8ZHpFvdRTJWDJWx+SWuqCo4BlDsgVCA91NTmdeH7t/LtgjOlS/C2uqRQtlcwqIi9GKw==
dependencies:
"@abp/aspnetcore.mvc.ui" "~10.2.0-rc.3"
"@abp/bootstrap" "~10.2.0-rc.3"
"@abp/bootstrap-datepicker" "~10.2.0-rc.3"
"@abp/bootstrap-daterangepicker" "~10.2.0-rc.3"
"@abp/datatables.net-bs5" "~10.2.0-rc.3"
"@abp/font-awesome" "~10.2.0-rc.3"
"@abp/jquery-validation-unobtrusive" "~10.2.0-rc.3"
"@abp/lodash" "~10.2.0-rc.3"
"@abp/luxon" "~10.2.0-rc.3"
"@abp/malihu-custom-scrollbar-plugin" "~10.2.0-rc.3"
"@abp/moment" "~10.2.0-rc.3"
"@abp/select2" "~10.2.0-rc.3"
"@abp/sweetalert2" "~10.2.0-rc.3"
"@abp/timeago" "~10.2.0-rc.3"
"@abp/aspnetcore.mvc.ui@~10.2.0-rc.3":
version "10.2.0-rc.3"
resolved "https://registry.yarnpkg.com/@abp/aspnetcore.mvc.ui/-/aspnetcore.mvc.ui-10.2.0-rc.3.tgz#4ddf1babf991772e38ecc11a5a61dcaa93b8521b"
integrity sha512-HKD0yw/xAMSSEfNv+xTSDfqGQVrrmenOdGe08EWa4N9wJywgbKsCVHuQva3zKlgMZ22EuF7MOFgEFwtKkizKoA==
"@abp/aspnetcore.mvc.ui.theme.shared@~10.3.0-rc.1":
version "10.3.0-rc.1"
resolved "https://registry.yarnpkg.com/@abp/aspnetcore.mvc.ui.theme.shared/-/aspnetcore.mvc.ui.theme.shared-10.3.0-rc.1.tgz#465bb75a8b974be7a7945099fd9055ee68e3c9fe"
integrity sha512-89ca3oXYFEkaKbC1+mNsn6U4x0bls5iffcp1ZqaY5LTfHUhO5tJXbNf5kiOiEwT9TCYwPUHyLbUd2+rT72MGFA==
dependencies:
"@abp/aspnetcore.mvc.ui" "~10.3.0-rc.1"
"@abp/bootstrap" "~10.3.0-rc.1"
"@abp/bootstrap-datepicker" "~10.3.0-rc.1"
"@abp/bootstrap-daterangepicker" "~10.3.0-rc.1"
"@abp/datatables.net-bs5" "~10.3.0-rc.1"
"@abp/font-awesome" "~10.3.0-rc.1"
"@abp/jquery-validation-unobtrusive" "~10.3.0-rc.1"
"@abp/lodash" "~10.3.0-rc.1"
"@abp/luxon" "~10.3.0-rc.1"
"@abp/malihu-custom-scrollbar-plugin" "~10.3.0-rc.1"
"@abp/moment" "~10.3.0-rc.1"
"@abp/select2" "~10.3.0-rc.1"
"@abp/sweetalert2" "~10.3.0-rc.1"
"@abp/timeago" "~10.3.0-rc.1"
"@abp/aspnetcore.mvc.ui@~10.3.0-rc.1":
version "10.3.0-rc.1"
resolved "https://registry.yarnpkg.com/@abp/aspnetcore.mvc.ui/-/aspnetcore.mvc.ui-10.3.0-rc.1.tgz#c5764ec2fcab13ee591863c54e59e33fe1da90b4"
integrity sha512-vmBsradTXcTEqHUXM5rTLYnsNNYJDeyyvz3kaLLqNwYhjo2FxL8D8IUZnp3Mk0qkBpYaYeGag5uxd06VDczDhg==
dependencies:
ansi-colors "^4.1.3"
"@abp/bootstrap-datepicker@~10.2.0-rc.3":
version "10.2.0-rc.3"
resolved "https://registry.yarnpkg.com/@abp/bootstrap-datepicker/-/bootstrap-datepicker-10.2.0-rc.3.tgz#9917c0081a34edae20d649a8b327bb3156929094"
integrity sha512-goU/TlVzCh6OEqmnu36R97fdgK3/4dByLoqwuy1ec/hoMKjaL1JC0wE+zZEQ9eRfaNpx3BhUv1Y1rsAZD09wLQ==
"@abp/bootstrap-datepicker@~10.3.0-rc.1":
version "10.3.0-rc.1"
resolved "https://registry.yarnpkg.com/@abp/bootstrap-datepicker/-/bootstrap-datepicker-10.3.0-rc.1.tgz#58586e2333116ef840c3abd44bb2b54f944c92b1"
integrity sha512-PEXWi22YfOBdbxCUfLkEAVL45DCxzDbNALFLfsCK3LPvXuL5Pw0YU0CrezAW54+1tzPpbWh1HLyEyaUAINz6Wg==
dependencies:
bootstrap-datepicker "^1.10.1"
"@abp/bootstrap-daterangepicker@~10.2.0-rc.3":
version "10.2.0-rc.3"
resolved "https://registry.yarnpkg.com/@abp/bootstrap-daterangepicker/-/bootstrap-daterangepicker-10.2.0-rc.3.tgz#fa525bf38f11c42564069832f75f4cff6dd8994c"
integrity sha512-S+KHEgpUYdgcFWra6Ca4bBfHg1O0xswBCklSsHKmKJRRnZQMKJqlau+rvD4GR8KS99vLztinxJvYAotd+f5Eow==
"@abp/bootstrap-daterangepicker@~10.3.0-rc.1":
version "10.3.0-rc.1"
resolved "https://registry.yarnpkg.com/@abp/bootstrap-daterangepicker/-/bootstrap-daterangepicker-10.3.0-rc.1.tgz#088b96754014b3c7a8884689574c2332cd5c292e"
integrity sha512-uZRh/Eh/3nyFe4KfMLRMud5LmUs2JFKSeA4KeRliGe1PD68E6NizP/ZiNzkYFP2K2p0nNUQrqmSe7ZFHVELN4A==
dependencies:
"@abp/moment" "~10.2.0-rc.3"
"@abp/moment" "~10.3.0-rc.1"
bootstrap-daterangepicker "^3.1.0"
"@abp/bootstrap@~10.2.0-rc.3":
version "10.2.0-rc.3"
resolved "https://registry.yarnpkg.com/@abp/bootstrap/-/bootstrap-10.2.0-rc.3.tgz#cfef1f95741f1a3566b26292b214ce671eff77c8"
integrity sha512-lcBqbs/kfqDQMzplZrM29Z4ZlT/qDMm47s0BwI/3dgdiN4D0fF6Nv+Q/W3zCCCWvWDFUtOXZQaz5+hbCp9o80g==
"@abp/bootstrap@~10.3.0-rc.1":
version "10.3.0-rc.1"
resolved "https://registry.yarnpkg.com/@abp/bootstrap/-/bootstrap-10.3.0-rc.1.tgz#fc1ccf88e689af336fec9c76d367bcaf66253961"
integrity sha512-vdhaz3ccmIflmufhNiQE/8I2RZdSSv3V4sbA2NC24csx8xl4J/R2JFhTyTLixpxOR+/p1cWw1F2oMYVzjV0+eg==
dependencies:
"@abp/core" "~10.2.0-rc.3"
"@abp/popper.js" "~10.2.0-rc.3"
"@abp/core" "~10.3.0-rc.1"
"@abp/popper.js" "~10.3.0-rc.1"
bootstrap "^5.3.8"
"@abp/clipboard@~10.2.0-rc.3":
version "10.2.0-rc.3"
resolved "https://registry.yarnpkg.com/@abp/clipboard/-/clipboard-10.2.0-rc.3.tgz#8319a1ddb6e6811d1149f72224be8ebd0d1ceb75"
integrity sha512-0UdFRAbkyPV7I2fP80rg3iWLPNg7ejopf5vU34gEBSJKeNow9u6UK0tkNO1IReNbPbb7QypwX+voDy1voVAxZw==
"@abp/clipboard@~10.3.0-rc.1":
version "10.3.0-rc.1"
resolved "https://registry.yarnpkg.com/@abp/clipboard/-/clipboard-10.3.0-rc.1.tgz#2350aba655b18510dea0ca50cb91e5e5960e9480"
integrity sha512-jKo5H0+FSA+1aDdIWDmyAwNP2nFf19x4nAHbclkwnsexHBRfQlvx6yB6sY85BP+Ft6zxIn9gRRLbJPvFwsKBWw==
dependencies:
"@abp/core" "~10.2.0-rc.3"
"@abp/core" "~10.3.0-rc.1"
clipboard "^2.0.11"
"@abp/core@~10.2.0-rc.3":
version "10.2.0-rc.3"
resolved "https://registry.yarnpkg.com/@abp/core/-/core-10.2.0-rc.3.tgz#63241199853f5d61bb24c83aacd49e9bbe77b9f5"
integrity sha512-cwis5nFMYWf5z1RH51GhiE9ZOj6hVOQ0QXk7qptgi0T3Uql5v3xlCzijhAJpiTgThamXS5J6vEaMjqRHKOfcIg==
"@abp/core@~10.3.0-rc.1":
version "10.3.0-rc.1"
resolved "https://registry.yarnpkg.com/@abp/core/-/core-10.3.0-rc.1.tgz#68f85db8219e3a2618a60cf82933bc52a2d1ec49"
integrity sha512-tOl/yDSPqRaHbENFVYcjqkdKGM4Qhjd/QPushVaH57pxalmv6canhiWAa0x7W31qxdY9od48H7MYoJx61jURhw==
dependencies:
"@abp/utils" "~10.2.0-rc.3"
"@abp/utils" "~10.3.0-rc.1"
"@abp/datatables.net-bs5@~10.2.0-rc.3":
version "10.2.0-rc.3"
resolved "https://registry.yarnpkg.com/@abp/datatables.net-bs5/-/datatables.net-bs5-10.2.0-rc.3.tgz#4eccb40f5833df148dda5dc12052746fce1d754b"
integrity sha512-OHbJuFS3FloST1/aLg84pd9PAP2kAtlRv4An8zWmdlxwxaWPNzF83uar6kF4KyZZn02rhQtzSwGF0aZr2ipUdw==
"@abp/datatables.net-bs5@~10.3.0-rc.1":
version "10.3.0-rc.1"
resolved "https://registry.yarnpkg.com/@abp/datatables.net-bs5/-/datatables.net-bs5-10.3.0-rc.1.tgz#55e8dde9024c51ccb70fe5b640bbb434fff2e00d"
integrity sha512-d6vFlrNgS6MIpXi2EcZjGFbFbCl5I5hzM49NxQeB3hyG7gUElFsEJkf66UcO8PN/56Uo+sFe/rCdrcvyLLDHmg==
dependencies:
"@abp/datatables.net" "~10.2.0-rc.3"
"@abp/datatables.net" "~10.3.0-rc.1"
datatables.net-bs5 "^2.3.4"
"@abp/datatables.net@~10.2.0-rc.3":
version "10.2.0-rc.3"
resolved "https://registry.yarnpkg.com/@abp/datatables.net/-/datatables.net-10.2.0-rc.3.tgz#87745a9518c50fa96446e23276896c7e14d20800"
integrity sha512-SEUNIBhnvLrNhE3FrsynzoXTt/n5ewoE0A+duPNAPS7ckJb9t3qfTcHqGpomZ+mjcuF5IM/3S0F1uNET+QyOAw==
"@abp/datatables.net@~10.3.0-rc.1":
version "10.3.0-rc.1"
resolved "https://registry.yarnpkg.com/@abp/datatables.net/-/datatables.net-10.3.0-rc.1.tgz#8e275f31d8b24b098c6acc3ec1de7ef2fe587e73"
integrity sha512-m57imr9KOkasgDCIFaIgoDqFDhS71wzClE2WCk+Uk7qHLRgRMa7CYRHh5xeck4hgPWq9DEmAZS0ogbHxLgkLKQ==
dependencies:
"@abp/jquery" "~10.2.0-rc.3"
"@abp/jquery" "~10.3.0-rc.1"
datatables.net "^2.3.4"
"@abp/font-awesome@~10.2.0-rc.3":
version "10.2.0-rc.3"
resolved "https://registry.yarnpkg.com/@abp/font-awesome/-/font-awesome-10.2.0-rc.3.tgz#44ed6883da8291a5e3f3281c116dabd593e8bf41"
integrity sha512-TzsLpl9aoRJOQrWyO/95hQvUpJ6vi6XAWUeAUOYJwMJpNN87Wvesbb5Umx4rJIe5+XGUPx33fMCJtQObhEnS8Q==
"@abp/font-awesome@~10.3.0-rc.1":
version "10.3.0-rc.1"
resolved "https://registry.yarnpkg.com/@abp/font-awesome/-/font-awesome-10.3.0-rc.1.tgz#e9d4a1b36652773509259440b77b804af18c5364"
integrity sha512-8zvZh7lbPlvH0/SKYtUeQF+ezKSoohwFxzFhOJ88GOdtwp620mBCwYEHjZA64e/Xjk6Oda2VrdNJ318IvgjIvA==
dependencies:
"@abp/core" "~10.2.0-rc.3"
"@abp/core" "~10.3.0-rc.1"
"@fortawesome/fontawesome-free" "^7.0.1"
"@abp/highlight.js@~10.2.0-rc.3":
version "10.2.0-rc.3"
resolved "https://registry.yarnpkg.com/@abp/highlight.js/-/highlight.js-10.2.0-rc.3.tgz#1dcdd6b438664d4b214a4eb10af895cddc882b11"
integrity sha512-RgQ2wN/UUP9rTefPRJpgka2PDv6S5h1+lzHa8x+Z7aSBP3kuNbPOPvLPB9gKsc/r5tZRIcnRWXcKPnQL57q/0A==
"@abp/highlight.js@~10.3.0-rc.1":
version "10.3.0-rc.1"
resolved "https://registry.yarnpkg.com/@abp/highlight.js/-/highlight.js-10.3.0-rc.1.tgz#76d699f549aa630afda1f822717a3d3e2d846e88"
integrity sha512-m43P+D3gOsbc6tr2qQ/OmpCU6IqfVkw1frq1J6DcwlpEf5rCFhSEuvSq7roe9OTqsFX0xa+a1X2BHRNc3CkS2Q==
dependencies:
"@abp/core" "~10.2.0-rc.3"
"@abp/core" "~10.3.0-rc.1"
"@highlightjs/cdn-assets" "~11.11.1"
"@abp/jquery-validation-unobtrusive@~10.2.0-rc.3":
version "10.2.0-rc.3"
resolved "https://registry.yarnpkg.com/@abp/jquery-validation-unobtrusive/-/jquery-validation-unobtrusive-10.2.0-rc.3.tgz#6b918a5a27057f4fe513001f8b66f8cc58a2c95b"
integrity sha512-U00D6Aau5X1lmKnTpRjflA/5zWkBy0K6OEuOs8jDp70FZr/Pj2tuSdz1Y7mxDeP3gLWxjCnRFK3X7sB8Z61zGw==
"@abp/jquery-validation-unobtrusive@~10.3.0-rc.1":
version "10.3.0-rc.1"
resolved "https://registry.yarnpkg.com/@abp/jquery-validation-unobtrusive/-/jquery-validation-unobtrusive-10.3.0-rc.1.tgz#b547557e85838afcc783ef3d502c2da3a31f4a63"
integrity sha512-NU00gPTHDF2HTnsHaZKAp17DTgAHUCz1fwuXRq4f7Ke5Q9HwiJx2IpIFaeR49xe9XRg1d9iRbNKFkMycjX7rEg==
dependencies:
"@abp/jquery-validation" "~10.2.0-rc.3"
"@abp/jquery-validation" "~10.3.0-rc.1"
jquery-validation-unobtrusive "^4.0.0"
"@abp/jquery-validation@~10.2.0-rc.3":
version "10.2.0-rc.3"
resolved "https://registry.yarnpkg.com/@abp/jquery-validation/-/jquery-validation-10.2.0-rc.3.tgz#d31fa3beeb8e81557015b59b9b27479966aa05f6"
integrity sha512-MAf3OnVaWbYcp9z6n1yzFsqmZaZdScTH7eCPQm+COO4+/M7Dl7Klbxc77Df7WBu2Cqih7B50WWAQZU6KUhb5iQ==
"@abp/jquery-validation@~10.3.0-rc.1":
version "10.3.0-rc.1"
resolved "https://registry.yarnpkg.com/@abp/jquery-validation/-/jquery-validation-10.3.0-rc.1.tgz#c4ab1a0692c1d224c5f60ffc4cb25545a7024c31"
integrity sha512-JaUhEbhKFB1DPFe+xTeIqTvN3YgFm7gdlpqMxN9Q5k0mnecQHlNmXbmC0oDa+CyxZDvMjWOrSMrJw/8yL4moQQ==
dependencies:
"@abp/jquery" "~10.2.0-rc.3"
"@abp/jquery" "~10.3.0-rc.1"
jquery-validation "^1.21.0"
"@abp/jquery@~10.2.0-rc.3":
version "10.2.0-rc.3"
resolved "https://registry.yarnpkg.com/@abp/jquery/-/jquery-10.2.0-rc.3.tgz#2c1c5737941ecf206379d2de52b5ef4ebe291bcd"
integrity sha512-P9+zBDE5kSzQF2hzmjNumIDSVNFf+J8FvGXLeOteAcZqy3Cvc1z1okZGFA7nM28QYaAFAVIegabhKF+5IiafIg==
"@abp/jquery@~10.3.0-rc.1":
version "10.3.0-rc.1"
resolved "https://registry.yarnpkg.com/@abp/jquery/-/jquery-10.3.0-rc.1.tgz#bc8d3a468483165f8d35cbfa5e91621c2fa53c8f"
integrity sha512-tCZsi5U3oWZOazxRuqrEPyE85jZ4D71IfSrT9/Brf1wAELyrttt5ke3P57yQqjBevbfSqW652Wxca/w2GidQcg==
dependencies:
"@abp/core" "~10.2.0-rc.3"
"@abp/core" "~10.3.0-rc.1"
jquery "~3.7.1"
"@abp/lodash@~10.2.0-rc.3":
version "10.2.0-rc.3"
resolved "https://registry.yarnpkg.com/@abp/lodash/-/lodash-10.2.0-rc.3.tgz#4f61145867f730da2aafd65c8c8dbe452c231142"
integrity sha512-bU55YMrq0pNLEzvUV1AujdCVP18rETrpCHjuFrqLi69weC73atLH1fAzQoWbVjKDXzZ8qOFM2s+naEtS5gEjAw==
"@abp/lodash@~10.3.0-rc.1":
version "10.3.0-rc.1"
resolved "https://registry.yarnpkg.com/@abp/lodash/-/lodash-10.3.0-rc.1.tgz#7bdf8038f28a822dfc12f0bfa9b7d81f13e16d99"
integrity sha512-tlev/kkQ3Q+OC7BT1ZJ+TThIbVQ3CZ+mtEujLt6zHVIxx8tukbYmmDMpltrmOL2kGnQeDdMqFcQoNJAAYOju+A==
dependencies:
"@abp/core" "~10.2.0-rc.3"
"@abp/core" "~10.3.0-rc.1"
lodash "^4.17.21"
"@abp/luxon@~10.2.0-rc.3":
version "10.2.0-rc.3"
resolved "https://registry.yarnpkg.com/@abp/luxon/-/luxon-10.2.0-rc.3.tgz#3ff6f8b4226c67be051851587a7847737c258de1"
integrity sha512-m/gngYrFg2EBB1PIK0M8jD2LlsYnwzlJVS5MV2vmGZ4h4tmq8HOYGrVPPLnvljaaNioYX3z0N1EeQEA4vQcogw==
"@abp/luxon@~10.3.0-rc.1":
version "10.3.0-rc.1"
resolved "https://registry.yarnpkg.com/@abp/luxon/-/luxon-10.3.0-rc.1.tgz#e13db7cade7761964e9820315e43bac182f80784"
integrity sha512-FB42gIKvfDJBu2vFZDBFVL6bn9jZpDpKdt5Kkp4OKQak9NHVyNuzH+R95Hr99vB+KELyTwW7hihc6Kl2oCrwWQ==
dependencies:
"@abp/core" "~10.2.0-rc.3"
"@abp/core" "~10.3.0-rc.1"
luxon "^3.7.2"
"@abp/malihu-custom-scrollbar-plugin@~10.2.0-rc.3":
version "10.2.0-rc.3"
resolved "https://registry.yarnpkg.com/@abp/malihu-custom-scrollbar-plugin/-/malihu-custom-scrollbar-plugin-10.2.0-rc.3.tgz#52eb0b0103b371be41fc89961335ed7a219cb10c"
integrity sha512-ZPcg1tehGz1dIOC7zqu/nsnV9mjPSoHb8TdhpAL6XERGAu2mwWvHBP2Zi7LE6PXYc7stF33i4u6Tr+OMMDrkrg==
"@abp/malihu-custom-scrollbar-plugin@~10.3.0-rc.1":
version "10.3.0-rc.1"
resolved "https://registry.yarnpkg.com/@abp/malihu-custom-scrollbar-plugin/-/malihu-custom-scrollbar-plugin-10.3.0-rc.1.tgz#1af6ebce0553c368d884752efcaef6cceaeb806c"
integrity sha512-+BYN/3DhhtI7tS4nv4BpdsPiQLEVrLQ0KgawGRr1h3SV/nz1dtBTBgTbKvy/4bDHoMSKA1dPfudxi6XjgwUoBA==
dependencies:
"@abp/core" "~10.2.0-rc.3"
"@abp/core" "~10.3.0-rc.1"
malihu-custom-scrollbar-plugin "^3.1.5"
"@abp/moment@~10.2.0-rc.3":
version "10.2.0-rc.3"
resolved "https://registry.yarnpkg.com/@abp/moment/-/moment-10.2.0-rc.3.tgz#991a8523b30d987930b6bf8b82647bbcae340f7c"
integrity sha512-s/r4BgDiqimPdNd1PBGMH/BbBtA77I1Mwmg/OaCUYYrUuWBGQHlsElO7sGEQwHGUfx0WjRG0wZ+jbV992qDhrA==
"@abp/moment@~10.3.0-rc.1":
version "10.3.0-rc.1"
resolved "https://registry.yarnpkg.com/@abp/moment/-/moment-10.3.0-rc.1.tgz#82229c44e7b1417c872edec53ed3f51d06bf7e8b"
integrity sha512-hKQ8VE25Wqo1mj/ks8qw39ONLQK5sV33xc0bfYHTk0+NVAholAPZF7gjqiDzaAWXzVBVJjemH554AM3y14VNAA==
dependencies:
moment "^2.30.1"
"@abp/popper.js@~10.2.0-rc.3":
version "10.2.0-rc.3"
resolved "https://registry.yarnpkg.com/@abp/popper.js/-/popper.js-10.2.0-rc.3.tgz#ad445921c951d297b6c8598062f06ee21c7a8bf0"
integrity sha512-tZd4+Q6xjKQJwe71jygVL9xIZqqTARyMOVJevCSEH9QU4OPPy2P0HW+hFatBNvHUKF7tuEl+i5PoQin5g/LANA==
"@abp/popper.js@~10.3.0-rc.1":
version "10.3.0-rc.1"
resolved "https://registry.yarnpkg.com/@abp/popper.js/-/popper.js-10.3.0-rc.1.tgz#3d758e25e7b09210542e4da06558cd5a1b710b45"
integrity sha512-3PKJpwzTYAQ6UJFe3u9bn1qKRjXwmILlR9umyH998bfJLr0Zl04rBfcbIL9/l3S01qlCMt2bwjre0+wpRl+/OA==
dependencies:
"@abp/core" "~10.2.0-rc.3"
"@abp/core" "~10.3.0-rc.1"
"@popperjs/core" "^2.11.8"
"@abp/prismjs@~10.2.0-rc.3":
version "10.2.0-rc.3"
resolved "https://registry.yarnpkg.com/@abp/prismjs/-/prismjs-10.2.0-rc.3.tgz#3f82a7af908f147c0e52a50e47297696fdb9deaa"
integrity sha512-jjY7rBANt0WgLjKveB62035r67KgoQGXnRagiiRLu1olv/oUELT/MR2ii5mR831hq8ULlH7QN0ERyrcgCzcO5A==
"@abp/prismjs@~10.3.0-rc.1":
version "10.3.0-rc.1"
resolved "https://registry.yarnpkg.com/@abp/prismjs/-/prismjs-10.3.0-rc.1.tgz#147852ca4e0f9ceebe5681b65ad440b48325021f"
integrity sha512-sAWkFAP8GPx4UBR2EXg9Uz0sivK9CTWccUGGecttK9mNf12YbtVftmS145Fgip2+ifklPTcVRjXWIghyJPYu5Q==
dependencies:
"@abp/clipboard" "~10.2.0-rc.3"
"@abp/core" "~10.2.0-rc.3"
"@abp/clipboard" "~10.3.0-rc.1"
"@abp/core" "~10.3.0-rc.1"
prismjs "^1.30.0"
"@abp/select2@~10.2.0-rc.3":
version "10.2.0-rc.3"
resolved "https://registry.yarnpkg.com/@abp/select2/-/select2-10.2.0-rc.3.tgz#d6ceac67fb4bb242cb231a870e9ee1bb2ca8ce02"
integrity sha512-aXuNqUg8+jK4nPVj6tsclOTqyXcRWC6a9rqEptSgpyoN4mE2Gsx8siIH8f8bdyZUMbww3ixTiTGKK5Hu7larrQ==
"@abp/select2@~10.3.0-rc.1":
version "10.3.0-rc.1"
resolved "https://registry.yarnpkg.com/@abp/select2/-/select2-10.3.0-rc.1.tgz#5292066ccf962ad92058d946b267d413c4876c40"
integrity sha512-gAJaAuLoHaOhkdDwS2F3ZH3CR//8EClkEWDde2AcYS+W2AY9/4g9kAMUOD5IrkrrYvJ+DDa9u426Ds8n8s/B9g==
dependencies:
"@abp/core" "~10.2.0-rc.3"
"@abp/core" "~10.3.0-rc.1"
select2 "^4.0.13"
"@abp/sweetalert2@~10.2.0-rc.3":
version "10.2.0-rc.3"
resolved "https://registry.yarnpkg.com/@abp/sweetalert2/-/sweetalert2-10.2.0-rc.3.tgz#69be81b56a6347d238cf07f6ae6a3c604740a910"
integrity sha512-gWodWf2YwMSULMkqOEzE7SNP7IFQt0EbV5fuuH71NkIaNIMHhnQgz76XiiFjN7CF4uZLoDmRSfIzx9RNzRpOqw==
"@abp/sweetalert2@~10.3.0-rc.1":
version "10.3.0-rc.1"
resolved "https://registry.yarnpkg.com/@abp/sweetalert2/-/sweetalert2-10.3.0-rc.1.tgz#a344674f14b21f430a820805bc36768a6dde1f58"
integrity sha512-UV3nSA3LdgygFr1ruFZRU1PVan455NMFILisXKRYVu9QwweFInmnliR6FmcDlTmPFW7RCLBk+lC9UblEcDthUA==
dependencies:
"@abp/core" "~10.2.0-rc.3"
"@abp/core" "~10.3.0-rc.1"
sweetalert2 "^11.23.0"
"@abp/timeago@~10.2.0-rc.3":
version "10.2.0-rc.3"
resolved "https://registry.yarnpkg.com/@abp/timeago/-/timeago-10.2.0-rc.3.tgz#b06dedf0f2363c697a7c6e1a0fb36d6b51537412"
integrity sha512-HDiUjIMV1cgDDC+w+P5irzq9C9lK1WAJcYxeyDg2UIbfhp8Na65syyeC34hRVOWFrUh3yvsJx1sw+2P2s5S9Og==
"@abp/timeago@~10.3.0-rc.1":
version "10.3.0-rc.1"
resolved "https://registry.yarnpkg.com/@abp/timeago/-/timeago-10.3.0-rc.1.tgz#f0e476b65f19fd986c06231a331fdb4050d1a6c6"
integrity sha512-ydBvTJ73BrV7A69vtWRzw//JmrLu6r4ow8J3GZy8vEuH9vlBWe1JHcPK6lOD3DDM+K6Jag5/31RL45ZuSSxcvQ==
dependencies:
"@abp/jquery" "~10.2.0-rc.3"
"@abp/jquery" "~10.3.0-rc.1"
timeago "^1.6.7"
"@abp/utils@~10.2.0-rc.3":
version "10.2.0-rc.3"
resolved "https://registry.yarnpkg.com/@abp/utils/-/utils-10.2.0-rc.3.tgz#3db132d3872c6f389a54c12bff343e756b5a39b2"
integrity sha512-MpI1G5hGs/YSL3g2sytSr5+cxhOsNDVe7k6lFWND+L8iECPROh1s8SAqDJ6eKxkQNwlwEtNc56Y+LdfJmKyeXA==
"@abp/utils@~10.3.0-rc.1":
version "10.3.0-rc.1"
resolved "https://registry.yarnpkg.com/@abp/utils/-/utils-10.3.0-rc.1.tgz#b6e20d357b87a009ffef3e11a0e7cc3a887d461f"
integrity sha512-tBcywC3T2ZUKS6znm1lCd+jP0peZolYyxjs1F7wyyH/y+Y08NYtGgLG/A8EaIv7iDY2K4WExzvWjTHB4lE1mFA==
dependencies:
just-compare "^2.3.0"

4
modules/basic-theme/test/Volo.Abp.AspNetCore.Mvc.UI.Theme.Basic.Demo/package.json

@ -3,8 +3,8 @@
"name": "asp.net",
"private": true,
"dependencies": {
"@abp/aspnetcore.mvc.ui.theme.basic": "~10.2.0-rc.3",
"@abp/prismjs": "~10.2.0-rc.3"
"@abp/aspnetcore.mvc.ui.theme.basic": "~10.3.0-rc.1",
"@abp/prismjs": "~10.3.0-rc.1"
},
"devDependencies": {}
}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save