diff --git a/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetEndpointsManifest.cs b/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetEndpointsManifest.cs index 0387bb131057..f996a75b7168 100644 --- a/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetEndpointsManifest.cs +++ b/src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetEndpointsManifest.cs @@ -116,6 +116,8 @@ public override bool Execute() endpoint.AssetFile = asset.ResolvedAsset.ComputeTargetPath("", '/', StaticWebAssetTokenResolver.Instance); endpoint.Route = route; + EncodeLinkHeadersIfNeeded(endpoint); + Log.LogMessage(MessageImportance.Low, "Including endpoint '{0}' for asset '{1}' with final location '{2}'", endpoint.Route, endpoint.AssetFile, asset.TargetPath); } @@ -137,6 +139,48 @@ public override bool Execute() return !Log.HasLoggedErrors; } + private static void EncodeLinkHeadersIfNeeded(StaticWebAssetEndpoint endpoint) + { + for (var i = 0; i < endpoint.ResponseHeaders.Length; i++) + { + ref var header = ref endpoint.ResponseHeaders[i]; + if (!string.Equals(header.Name, "Link", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + var headerValues = header.Value.Split([','], StringSplitOptions.RemoveEmptyEntries); + for (var j = 0; j < headerValues.Length; j++) + { + ref var value = ref headerValues[j]; + value = EncodeHeaderValue(value); + } + header.Value = string.Join(",", headerValues); + } + } + + private static string EncodeHeaderValue(string header) + { + var index = header.IndexOf('<'); + if (index == -1) + { + return header; + } + index++; + var endIndex = header.IndexOf('>', index); + if (endIndex == -1) + { + return header; + } + var link = header.AsSpan(index, endIndex - index).ToString(); + var segments = link.Split('/'); + for (var j = 0; j < segments.Length; j++) + { + segments[j] = System.Net.WebUtility.UrlEncode(segments[j]); + } + var encoded = string.Join("/", segments); + return $"{header.Substring(0, index)}{encoded}{header.Substring(endIndex)}"; + } + private static (string, string[]) ParseAndSortPatterns(string patterns) { if (string.IsNullOrEmpty(patterns)) diff --git a/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/ScopedCssIntegrationTests.cs b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/ScopedCssIntegrationTests.cs index 9668d8656913..6155a3498987 100644 --- a/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/ScopedCssIntegrationTests.cs +++ b/test/Microsoft.NET.Sdk.StaticWebAssets.Tests/ScopedCssIntegrationTests.cs @@ -3,6 +3,7 @@ #nullable disable +using System.Text.Json; using System.Text.RegularExpressions; using Microsoft.AspNetCore.StaticWebAssets.Tasks; @@ -617,5 +618,72 @@ public void RegeneratingScopedCss_ForProjectWithReferences() text.Should().Contain("background-color: orangered"); text.Should().MatchRegex(""".*@import '_content/ClassLibrary/ClassLibrary\.[a-zA-Z0-9]+\.bundle\.scp\.css.*"""); } + + [Fact] + public void Build_GeneratesUrlEncodedLinkHeaderForNonAsciiProjectName() + { + var testAsset = "RazorAppWithPackageAndP2PReference"; + ProjectDirectory = CreateAspNetSdkTestAsset(testAsset); + + // Rename the ClassLibrary project to have non-ASCII characters + var originalLibPath = Path.Combine(ProjectDirectory.Path, "AnotherClassLib"); + var newLibPath = Path.Combine(ProjectDirectory.Path, "项目"); + Directory.Move(originalLibPath, newLibPath); + + // Update the project file to set the assembly name and package ID + var libProjectFile = Path.Combine(newLibPath, "AnotherClassLib.csproj"); + var newLibProjectFile = Path.Combine(newLibPath, "项目.csproj"); + File.Move(libProjectFile, newLibProjectFile); + + // Add assembly name property to ensure consistent naming + var libProjectContent = File.ReadAllText(newLibProjectFile); + // Find the first PropertyGroup closing tag and replace it + var targetPattern = ""; + var replacement = " 项目\n 项目\n "; + var index = libProjectContent.IndexOf(targetPattern); + if (index >= 0) + { + libProjectContent = libProjectContent.Substring(0, index) + replacement + libProjectContent.Substring(index + targetPattern.Length); + } + File.WriteAllText(newLibProjectFile, libProjectContent); + + // Update the main project to reference the renamed library + var mainProjectFile = Path.Combine(ProjectDirectory.Path, "AppWithPackageAndP2PReference", "AppWithPackageAndP2PReference.csproj"); + var mainProjectContent = File.ReadAllText(mainProjectFile); + mainProjectContent = mainProjectContent.Replace(@"..\AnotherClassLib\AnotherClassLib.csproj", @"..\项目\项目.csproj"); + File.WriteAllText(mainProjectFile, mainProjectContent); + + // Ensure library has scoped CSS + var libCssFile = Path.Combine(newLibPath, "Views", "Shared", "Index.cshtml.css"); + if (!File.Exists(libCssFile)) + { + Directory.CreateDirectory(Path.GetDirectoryName(libCssFile)); + File.WriteAllText(libCssFile, ".test { color: red; }"); + } + + EnsureLocalPackagesExists(); + + var restore = CreateRestoreCommand(ProjectDirectory, "AppWithPackageAndP2PReference"); + ExecuteCommand(restore).Should().Pass(); + + var build = CreateBuildCommand(ProjectDirectory, "AppWithPackageAndP2PReference"); + ExecuteCommand(build).Should().Pass(); + + var intermediateOutputPath = build.GetIntermediateDirectory(DefaultTfm, "Debug").ToString(); + + // Check that the staticwebassets.build.endpoints.json file contains URL-encoded characters + var endpointsFile = Path.Combine(intermediateOutputPath, "staticwebassets.build.endpoints.json"); + new FileInfo(endpointsFile).Should().Exist(); + + var endpointsContent = File.ReadAllText(endpointsFile); + var json = JsonSerializer.Deserialize(endpointsContent, new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + var styles = json.Endpoints.Where(e => e.Route.EndsWith("styles.css")); + + foreach (var styleEndpoint in styles) + { + styleEndpoint.ResponseHeaders.Should().Contain(h => h.Name.Equals("Link", StringComparison.OrdinalIgnoreCase) && h.Value.Contains("%E9%A1%B9%E7%9B%AE")); + } + } } }