diff --git a/framework/Volo.Abp.slnx b/framework/Volo.Abp.slnx
index 26d462fb4f..fa76c5930d 100644
--- a/framework/Volo.Abp.slnx
+++ b/framework/Volo.Abp.slnx
@@ -180,6 +180,7 @@
+
diff --git a/framework/src/Volo.Abp.AspNetCore.Components.Web.Theming/Bundling/AbpScripts.razor b/framework/src/Volo.Abp.AspNetCore.Components.Web.Theming/Bundling/AbpScripts.razor
index 436a22fc0e..a607815421 100644
--- a/framework/src/Volo.Abp.AspNetCore.Components.Web.Theming/Bundling/AbpScripts.razor
+++ b/framework/src/Volo.Abp.AspNetCore.Components.Web.Theming/Bundling/AbpScripts.razor
@@ -1,16 +1,14 @@
+@using Microsoft.AspNetCore.Components
@implements IDisposable
@inject IComponentBundleManager BundleManager
@inject PersistentComponentState ApplicationState
+@inject NavigationManager NavigationManager
+@inject IComponentBundleUrlBuilder BundleUrlBuilder
@if (ScriptFiles != null)
{
foreach (var file in ScriptFiles)
{
- var src = file;
- if (!AppBasePath.IsNullOrWhiteSpace())
- {
- src = AppBasePath.EnsureEndsWith('/') + file.RemovePreFix("/");
- }
-
+
}
}
@@ -39,7 +37,8 @@
// We are in prerendering mode
if (!BundleName.IsNullOrWhiteSpace())
{
- ScriptFiles = (await BundleManager.GetScriptBundleFilesAsync(BundleName!)).ToList();
+ var rawFiles = await BundleManager.GetScriptBundleFilesAsync(BundleName!);
+ ScriptFiles = await ResolveAsync(rawFiles);
}
}
else
@@ -51,6 +50,16 @@
}
}
+ private async Task> ResolveAsync(IReadOnlyList files)
+ {
+ var resolved = new List(files.Count);
+ foreach (var file in files)
+ {
+ resolved.Add(await BundleUrlBuilder.BuildAsync(file, AppBasePath, NavigationManager.BaseUri));
+ }
+ return resolved;
+ }
+
private Task Callback()
{
ApplicationState.PersistAsJson(PrerenderedKey, PrerenderedKey);
diff --git a/framework/src/Volo.Abp.AspNetCore.Components.Web.Theming/Bundling/AbpStyles.razor b/framework/src/Volo.Abp.AspNetCore.Components.Web.Theming/Bundling/AbpStyles.razor
index 1c2ede488f..2ebdf1aecf 100644
--- a/framework/src/Volo.Abp.AspNetCore.Components.Web.Theming/Bundling/AbpStyles.razor
+++ b/framework/src/Volo.Abp.AspNetCore.Components.Web.Theming/Bundling/AbpStyles.razor
@@ -1,16 +1,14 @@
+@using Microsoft.AspNetCore.Components
@implements IDisposable
@inject IComponentBundleManager BundleManager
@inject PersistentComponentState ApplicationState
+@inject NavigationManager NavigationManager
+@inject IComponentBundleUrlBuilder BundleUrlBuilder
@if (StyleFiles != null)
{
foreach (var file in StyleFiles)
{
- var href = file;
- if (!AppBasePath.IsNullOrWhiteSpace())
- {
- href = AppBasePath.EnsureEndsWith('/') + file.RemovePreFix("/");
- }
-
+
}
}
@@ -39,7 +37,8 @@
// We are in prerendering mode
if (!BundleName.IsNullOrWhiteSpace())
{
- StyleFiles = (await BundleManager.GetStyleBundleFilesAsync(BundleName!)).ToList();
+ var rawFiles = await BundleManager.GetStyleBundleFilesAsync(BundleName!);
+ StyleFiles = await ResolveAsync(rawFiles);
}
}
else
@@ -65,6 +64,16 @@
}
}
+ private async Task> ResolveAsync(IReadOnlyList files)
+ {
+ var resolved = new List(files.Count);
+ foreach (var file in files)
+ {
+ resolved.Add(await BundleUrlBuilder.BuildAsync(file, AppBasePath, NavigationManager.BaseUri));
+ }
+ return resolved;
+ }
+
private Task Callback()
{
ApplicationState.PersistAsJson(PrerenderedKey, StyleFiles);
diff --git a/framework/src/Volo.Abp.AspNetCore.Components.Web.Theming/Bundling/ComponentBundleUrlBuilder.cs b/framework/src/Volo.Abp.AspNetCore.Components.Web.Theming/Bundling/ComponentBundleUrlBuilder.cs
new file mode 100644
index 0000000000..7d27f1711c
--- /dev/null
+++ b/framework/src/Volo.Abp.AspNetCore.Components.Web.Theming/Bundling/ComponentBundleUrlBuilder.cs
@@ -0,0 +1,57 @@
+using System;
+using System.Threading.Tasks;
+using JetBrains.Annotations;
+using Volo.Abp.DependencyInjection;
+
+namespace Volo.Abp.AspNetCore.Components.Web.Theming.Bundling;
+
+public class ComponentBundleUrlBuilder : IComponentBundleUrlBuilder, ITransientDependency
+{
+ public virtual Task BuildAsync(
+ [NotNull] string fileName,
+ [CanBeNull] string? appBasePath,
+ [CanBeNull] string? navigationBaseUri)
+ {
+ Check.NotNull(fileName, nameof(fileName));
+
+ if (IsExternalUrl(fileName))
+ {
+ return Task.FromResult(fileName);
+ }
+
+ var pathBase = !string.IsNullOrWhiteSpace(appBasePath)
+ ? appBasePath
+ : ExtractPathBaseFromNavigationBaseUri(navigationBaseUri);
+
+ if (string.IsNullOrWhiteSpace(pathBase))
+ {
+ return Task.FromResult(fileName);
+ }
+
+ var normalized = pathBase.EnsureEndsWith('/');
+ if (normalized == "/")
+ {
+ return Task.FromResult(fileName);
+ }
+
+ return Task.FromResult(normalized + fileName.RemovePreFix("/"));
+ }
+
+ protected virtual bool IsExternalUrl([NotNull] string fileName)
+ {
+ return fileName.StartsWith("//", StringComparison.Ordinal) ||
+ (fileName.Contains(':') && Uri.TryCreate(fileName, UriKind.Absolute, out _));
+ }
+
+ protected virtual string? ExtractPathBaseFromNavigationBaseUri([CanBeNull] string? navigationBaseUri)
+ {
+ if (string.IsNullOrWhiteSpace(navigationBaseUri))
+ {
+ return null;
+ }
+
+ return Uri.TryCreate(navigationBaseUri, UriKind.Absolute, out var uri)
+ ? uri.AbsolutePath
+ : null;
+ }
+}
diff --git a/framework/src/Volo.Abp.AspNetCore.Components.Web.Theming/Bundling/IComponentBundleUrlBuilder.cs b/framework/src/Volo.Abp.AspNetCore.Components.Web.Theming/Bundling/IComponentBundleUrlBuilder.cs
new file mode 100644
index 0000000000..6db6915781
--- /dev/null
+++ b/framework/src/Volo.Abp.AspNetCore.Components.Web.Theming/Bundling/IComponentBundleUrlBuilder.cs
@@ -0,0 +1,8 @@
+using System.Threading.Tasks;
+
+namespace Volo.Abp.AspNetCore.Components.Web.Theming.Bundling;
+
+public interface IComponentBundleUrlBuilder
+{
+ Task BuildAsync(string fileName, string? appBasePath, string? navigationBaseUri);
+}
diff --git a/framework/test/Volo.Abp.AspNetCore.Components.Web.Theming.Tests/Volo.Abp.AspNetCore.Components.Web.Theming.Tests.abppkg b/framework/test/Volo.Abp.AspNetCore.Components.Web.Theming.Tests/Volo.Abp.AspNetCore.Components.Web.Theming.Tests.abppkg
new file mode 100644
index 0000000000..64c1552e37
--- /dev/null
+++ b/framework/test/Volo.Abp.AspNetCore.Components.Web.Theming.Tests/Volo.Abp.AspNetCore.Components.Web.Theming.Tests.abppkg
@@ -0,0 +1,3 @@
+{
+ "role": "lib.test"
+}
diff --git a/framework/test/Volo.Abp.AspNetCore.Components.Web.Theming.Tests/Volo.Abp.AspNetCore.Components.Web.Theming.Tests.csproj b/framework/test/Volo.Abp.AspNetCore.Components.Web.Theming.Tests/Volo.Abp.AspNetCore.Components.Web.Theming.Tests.csproj
new file mode 100644
index 0000000000..4e317e99ce
--- /dev/null
+++ b/framework/test/Volo.Abp.AspNetCore.Components.Web.Theming.Tests/Volo.Abp.AspNetCore.Components.Web.Theming.Tests.csproj
@@ -0,0 +1,17 @@
+
+
+
+
+
+ net10.0
+
+ enable
+
+
+
+
+
+
+
+
+
diff --git a/framework/test/Volo.Abp.AspNetCore.Components.Web.Theming.Tests/Volo/Abp/AspNetCore/Components/Web/Theming/Bundling/ComponentBundleUrlBuilder_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Components.Web.Theming.Tests/Volo/Abp/AspNetCore/Components/Web/Theming/Bundling/ComponentBundleUrlBuilder_Tests.cs
new file mode 100644
index 0000000000..a47250aaa4
--- /dev/null
+++ b/framework/test/Volo.Abp.AspNetCore.Components.Web.Theming.Tests/Volo/Abp/AspNetCore/Components/Web/Theming/Bundling/ComponentBundleUrlBuilder_Tests.cs
@@ -0,0 +1,105 @@
+using System.Threading.Tasks;
+using Shouldly;
+using Xunit;
+
+namespace Volo.Abp.AspNetCore.Components.Web.Theming.Bundling;
+
+public class ComponentBundleUrlBuilder_Tests
+{
+ private readonly IComponentBundleUrlBuilder _builder = new ComponentBundleUrlBuilder();
+
+ [Fact]
+ public async Task Should_Return_FileName_When_No_PathBase_Available()
+ {
+ (await _builder.BuildAsync("/__bundles/Global.css", appBasePath: null, navigationBaseUri: null))
+ .ShouldBe("/__bundles/Global.css");
+ }
+
+ [Fact]
+ public async Task Should_Return_FileName_When_NavigationBaseUri_Has_Root_PathBase()
+ {
+ (await _builder.BuildAsync("/__bundles/Global.css", appBasePath: null, navigationBaseUri: "https://localhost/"))
+ .ShouldBe("/__bundles/Global.css");
+ }
+
+ [Fact]
+ public async Task Should_Use_Explicit_AppBasePath_Over_NavigationBaseUri()
+ {
+ (await _builder.BuildAsync("/__bundles/Global.css", appBasePath: "/explicit", navigationBaseUri: "https://localhost/from-nav/"))
+ .ShouldBe("/explicit/__bundles/Global.css");
+ }
+
+ [Fact]
+ public async Task Should_Resolve_PathBase_From_NavigationBaseUri()
+ {
+ (await _builder.BuildAsync("/__bundles/Global.css", appBasePath: null, navigationBaseUri: "https://localhost/foo/"))
+ .ShouldBe("/foo/__bundles/Global.css");
+ }
+
+ [Fact]
+ public async Task Should_Resolve_PathBase_From_NavigationBaseUri_When_AppBasePath_Empty()
+ {
+ (await _builder.BuildAsync("/__bundles/Global.css", appBasePath: "", navigationBaseUri: "https://localhost/foo/"))
+ .ShouldBe("/foo/__bundles/Global.css");
+ }
+
+ [Fact]
+ public async Task Should_Handle_Nested_PathBase()
+ {
+ (await _builder.BuildAsync("/__bundles/Global.css", appBasePath: null, navigationBaseUri: "https://localhost/foo/bar/"))
+ .ShouldBe("/foo/bar/__bundles/Global.css");
+ }
+
+ [Fact]
+ public async Task Should_Normalize_AppBasePath_Without_Trailing_Slash()
+ {
+ (await _builder.BuildAsync("/__bundles/Global.css", appBasePath: "/foo", navigationBaseUri: null))
+ .ShouldBe("/foo/__bundles/Global.css");
+ }
+
+ [Fact]
+ public async Task Should_Not_Duplicate_Leading_Slash()
+ {
+ (await _builder.BuildAsync("/__bundles/Global.css", appBasePath: "/foo/", navigationBaseUri: null))
+ .ShouldBe("/foo/__bundles/Global.css");
+ }
+
+ [Fact]
+ public async Task Should_Handle_FileName_Without_Leading_Slash()
+ {
+ (await _builder.BuildAsync("__bundles/Global.css", appBasePath: "/foo", navigationBaseUri: null))
+ .ShouldBe("/foo/__bundles/Global.css");
+ }
+
+ [Fact]
+ public async Task Should_Return_FileName_When_NavigationBaseUri_Is_Invalid()
+ {
+ (await _builder.BuildAsync("/__bundles/Global.css", appBasePath: null, navigationBaseUri: "not-a-uri"))
+ .ShouldBe("/__bundles/Global.css");
+ }
+
+ [Fact]
+ public async Task Should_Throw_When_FileName_Is_Null()
+ {
+ await Should.ThrowAsync(async () =>
+ await _builder.BuildAsync(null!, appBasePath: "/foo", navigationBaseUri: null));
+ }
+
+ [Theory]
+ [InlineData("https://cdn.example.com/foo.css")]
+ [InlineData("http://cdn.example.com/foo.css")]
+ [InlineData("//cdn.example.com/foo.css")]
+ [InlineData("data:text/css;base64,Zm9vIA==")]
+ public async Task Should_Not_Prefix_External_Urls(string externalUrl)
+ {
+ (await _builder.BuildAsync(externalUrl, appBasePath: "/foo", navigationBaseUri: "https://localhost/foo/"))
+ .ShouldBe(externalUrl);
+ }
+
+ [Fact]
+ public async Task Should_Treat_Whitespace_AppBasePath_As_Not_Provided()
+ {
+ (await _builder.BuildAsync("/__bundles/Global.css", appBasePath: " ", navigationBaseUri: "https://localhost/foo/"))
+ .ShouldBe("/foo/__bundles/Global.css");
+ }
+}