diff --git a/src/Razor/Microsoft.NET.Sdk.Razor/src/GenerateStaticWebAssetsManifest.cs b/src/Razor/Microsoft.NET.Sdk.Razor/src/GenerateStaticWebAssetsManifest.cs index 3ab75b0452..b8d5d717df 100644 --- a/src/Razor/Microsoft.NET.Sdk.Razor/src/GenerateStaticWebAssetsManifest.cs +++ b/src/Razor/Microsoft.NET.Sdk.Razor/src/GenerateStaticWebAssetsManifest.cs @@ -17,6 +17,7 @@ namespace Microsoft.AspNetCore.Razor.Tasks { private const string ContentRoot = "ContentRoot"; private const string BasePath = "BasePath"; + private const string SourceId = "SourceId"; [Required] public string TargetManifestPath { get; set; } @@ -76,13 +77,19 @@ namespace Microsoft.AspNetCore.Razor.Tasks // so it needs to always be '/'. var normalizedBasePath = basePath.Replace("\\", "/"); + // contentRoot can have forward and trailing slashes and sometimes consecutive directory + // separators. To be more flexible we will normalize the content root so that it contains a + // single trailing separator. + var normalizedContentRoot = $"{contentRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)}{Path.DirectorySeparatorChar}"; + // At this point we already know that there are no elements with different base paths and same content roots // or viceversa. Here we simply skip additional items that have the same base path and same content root. - if (!nodes.Exists(e => e.Attribute(BasePath).Value.Equals(normalizedBasePath, StringComparison.OrdinalIgnoreCase))) + if (!nodes.Exists(e => e.Attribute("BasePath").Value.Equals(normalizedBasePath, StringComparison.OrdinalIgnoreCase) && + e.Attribute("Path").Value.Equals(normalizedContentRoot, StringComparison.OrdinalIgnoreCase))) { nodes.Add(new XElement("ContentRoot", new XAttribute("BasePath", normalizedBasePath), - new XAttribute("Path", contentRoot))); + new XAttribute("Path", normalizedContentRoot))); } } @@ -102,7 +109,8 @@ namespace Microsoft.AspNetCore.Razor.Tasks { var contentRootDefinition = ContentRootDefinitions[i]; if (!EnsureRequiredMetadata(contentRootDefinition, BasePath) || - !EnsureRequiredMetadata(contentRootDefinition, ContentRoot)) + !EnsureRequiredMetadata(contentRootDefinition, ContentRoot) || + !EnsureRequiredMetadata(contentRootDefinition, SourceId)) { return false; } @@ -126,23 +134,35 @@ namespace Microsoft.AspNetCore.Razor.Tasks var contentRootDefinition = ContentRootDefinitions[i]; var basePath = contentRootDefinition.GetMetadata(BasePath); var contentRoot = contentRootDefinition.GetMetadata(ContentRoot); + var sourceId = contentRootDefinition.GetMetadata(SourceId); if (basePaths.TryGetValue(basePath, out var existingBasePath)) { var existingBasePathContentRoot = existingBasePath.GetMetadata(ContentRoot); - if (!string.Equals(contentRoot, existingBasePathContentRoot, StringComparison.OrdinalIgnoreCase)) + var existingSourceId = existingBasePath.GetMetadata(SourceId); + if (!string.Equals(contentRoot, existingBasePathContentRoot, StringComparison.OrdinalIgnoreCase) && + // We want to check this case to allow for client-side blazor projects to have multiple different content + // root sources exposed under the same base path while still requiring unique base paths/content roots across + // project/package boundaries. + !string.Equals(sourceId, existingSourceId, StringComparison.OrdinalIgnoreCase)) { // Case: - // Item1: /_content/Library, /package/aspnetContent1 - // Item2: /_content/Library, /package/aspnetContent2 + // Item2: /_content/Library, project:/project/aspnetContent2 + // Item1: /_content/Library, package:/package/aspnetContent1 Log.LogError($"Duplicate base paths '{basePath}' for content root paths '{contentRoot}' and '{existingBasePathContentRoot}'. " + $"('{contentRootDefinition.ItemSpec}', '{existingBasePath.ItemSpec}')"); return false; } + // It was a duplicate, so we skip it. // Case: - // Item1: /_content/Library, /package/aspnetContent - // Item2: /_content/Library, /package/aspnetContent + // Item1: /_content/Library, project:/project/aspnetContent + // Item2: /_content/Library, project:/project/aspnetContent + + // It was a separate content root exposed from the same project/package, so we skip it. + // Case: + // Item1: /_content/Library, project:/project/aspnetContent/bin/debug/netstandard2.1/dist + // Item2: /_content/Library, project:/project/wwwroot } else { diff --git a/src/Razor/Microsoft.NET.Sdk.Razor/src/ValidateStaticWebAssetsUniquePaths.cs b/src/Razor/Microsoft.NET.Sdk.Razor/src/ValidateStaticWebAssetsUniquePaths.cs new file mode 100644 index 0000000000..23ec446549 --- /dev/null +++ b/src/Razor/Microsoft.NET.Sdk.Razor/src/ValidateStaticWebAssetsUniquePaths.cs @@ -0,0 +1,86 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.IO; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +namespace Microsoft.AspNetCore.Razor.Tasks +{ + public class ValidateStaticWebAssetsUniquePaths : Task + { + private const string BasePath = "BasePath"; + private const string RelativePath = "RelativePath"; + private const string TargetPath = "TargetPath"; + + [Required] + public ITaskItem[] StaticWebAssets { get; set; } + + [Required] + public ITaskItem[] WebRootFiles { get; set; } + + public override bool Execute() + { + var assetsByWebRootPaths = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (var i = 0; i < StaticWebAssets.Length; i++) + { + var contentRootDefinition = StaticWebAssets[i]; + if (!EnsureRequiredMetadata(contentRootDefinition, BasePath) || + !EnsureRequiredMetadata(contentRootDefinition, RelativePath)) + { + return false; + } + else + { + var webRootPath = GetWebRootPath( + contentRootDefinition.GetMetadata(BasePath), + contentRootDefinition.GetMetadata(RelativePath)); + + if (assetsByWebRootPaths.TryGetValue(webRootPath, out var existingWebRootPath)) + { + if (!string.Equals(contentRootDefinition.ItemSpec, existingWebRootPath.ItemSpec, StringComparison.OrdinalIgnoreCase)) + { + Log.LogError($"Conflicting assets with the same path '{webRootPath}' for content root paths '{contentRootDefinition.ItemSpec}' and '{existingWebRootPath.ItemSpec}'."); + return false; + } + } + else + { + assetsByWebRootPaths.Add(webRootPath, contentRootDefinition); + } + } + } + + for (var i = 0; i < WebRootFiles.Length; i++) + { + var webRootFile = WebRootFiles[i]; + var relativePath = webRootFile.GetMetadata(TargetPath); + var webRootFileWebRootPath = GetWebRootPath("/", relativePath); + if (assetsByWebRootPaths.TryGetValue(webRootFileWebRootPath, out var existingAsset)) + { + Log.LogError($"The static web asset '{existingAsset.ItemSpec}' has a conflicting web root path '{webRootFileWebRootPath}' with the project file '{webRootFile.ItemSpec}'."); + return false; + } + } + + return true; + } + + // Normalizes /base/relative \base\relative\ base\relative and so on to /base/relative + private string GetWebRootPath(string basePath, string relativePath) => $"/{Path.Combine(basePath, relativePath.TrimStart('.').TrimStart('/')).Replace("\\", "/").Trim('/')}"; + + private bool EnsureRequiredMetadata(ITaskItem item, string metadataName) + { + var value = item.GetMetadata(metadataName); + if (string.IsNullOrEmpty(value)) + { + Log.LogError($"Missing required metadata '{metadataName}' for '{item.ItemSpec}'."); + return false; + } + + return true; + } + } +} diff --git a/src/Razor/Microsoft.NET.Sdk.Razor/src/build/netstandard2.0/Microsoft.NET.Sdk.Razor.StaticWebAssets.targets b/src/Razor/Microsoft.NET.Sdk.Razor/src/build/netstandard2.0/Microsoft.NET.Sdk.Razor.StaticWebAssets.targets index a200ec63ab..d233f8682f 100644 --- a/src/Razor/Microsoft.NET.Sdk.Razor/src/build/netstandard2.0/Microsoft.NET.Sdk.Razor.StaticWebAssets.targets +++ b/src/Razor/Microsoft.NET.Sdk.Razor/src/build/netstandard2.0/Microsoft.NET.Sdk.Razor.StaticWebAssets.targets @@ -32,6 +32,11 @@ Copyright (c) .NET Foundation. All rights reserved. AssemblyFile="$(RazorSdkBuildTasksAssembly)" Condition="'$(RazorSdkBuildTasksAssembly)' != ''" /> + + %(StaticWebAsset.BasePath) %(StaticWebAsset.ContentRoot) + %(StaticWebAsset.SourceId) - + @@ -181,6 +187,15 @@ Copyright (c) .NET Foundation. All rights reserved. Outputs="$(_GeneratedStaticWebAssetsDevelopmentManifest)" DependsOnTargets="$(GenerateStaticWebAssetsManifestDependsOn)"> + + <_WebRootFiles Include="@(ContentWithTargetPath)" Condition="$([System.String]::Copy('%(TargetPath)').Replace('\','/').StartsWith('wwwroot/'))" /> + <_ReferencedStaticWebAssets Include="@(StaticWebAsset)" Condition="'%(SourceType)' != ''" /> + + + + @@ -273,7 +288,7 @@ Copyright (c) .NET Foundation. All rights reserved. <_ThisProjectStaticWebAsset Include="@(Content)" - Condition="$([System.String]::Copy('%(Identity)').StartsWith('wwwroot'))"> + Condition="$([System.String]::Copy('%(Identity)').Replace('\','/').StartsWith('wwwroot/'))"> $([System.String]::Copy('%(Identity)').Substring(8)) diff --git a/src/Razor/Microsoft.NET.Sdk.Razor/test/GenerateAspNetCoreStaticAssetsManifestTest.cs b/src/Razor/Microsoft.NET.Sdk.Razor/test/GenerateAspNetCoreStaticAssetsManifestTest.cs index 8bb80e5c2c..bc89a9b185 100644 --- a/src/Razor/Microsoft.NET.Sdk.Razor/test/GenerateAspNetCoreStaticAssetsManifestTest.cs +++ b/src/Razor/Microsoft.NET.Sdk.Razor/test/GenerateAspNetCoreStaticAssetsManifestTest.cs @@ -89,12 +89,14 @@ namespace Microsoft.AspNetCore.Razor.Tasks CreateItem(Path.Combine("wwwroot","sample.js"), new Dictionary { ["BasePath"] = "MyLibrary", - ["ContentRoot"] = Path.Combine("nuget","MyLibrary") + ["ContentRoot"] = Path.Combine("nuget", "MyLibrary"), + ["SourceId"] = "MyLibrary" }), CreateItem(Path.Combine("wwwroot", "otherLib.js"), new Dictionary { ["BasePath"] = "MyLibrary", - ["ContentRoot"] = Path.Combine("nuget", "MyOtherLibrary") + ["ContentRoot"] = Path.Combine("nuget", "MyOtherLibrary"), + ["SourceId"] = "MyOtherLibrary" }) } }; @@ -111,6 +113,58 @@ namespace Microsoft.AspNetCore.Razor.Tasks message); } + [Fact] + public void AllowsMultipleContentRootsWithSameBasePath_ForTheSameSourceId() + { + // Arrange + var file = Path.GetTempFileName(); + var expectedDocument = $@" + + +"; + + var buildEngine = new Mock(); + + var task = new GenerateStaticWebAssetsManifest + { + BuildEngine = buildEngine.Object, + ContentRootDefinitions = new TaskItem[] + { + CreateItem(Path.Combine("wwwroot","sample.js"), new Dictionary + { + ["BasePath"] = "Blazor.Client", + ["ContentRoot"] = Path.Combine(".", "nuget","Blazor.Client"), + ["SourceId"] = "Blazor.Client" + }), + CreateItem(Path.Combine("wwwroot", "otherLib.js"), new Dictionary + { + ["BasePath"] = "Blazor.Client", + ["ContentRoot"] = Path.Combine(".", "nuget", "bin","debug","netstandard2.1"), + ["SourceId"] = "Blazor.Client" + }) + }, + TargetManifestPath = file + }; + + try + { + // Act + var result = task.Execute(); + + // Assert + Assert.True(result); + var document = File.ReadAllText(file); + Assert.Equal(expectedDocument, document); + } + finally + { + if (File.Exists(file)) + { + File.Delete(file); + } + } + } + [Fact] public void ReturnsError_ForDuplicateContentRoots() { @@ -128,11 +182,13 @@ namespace Microsoft.AspNetCore.Razor.Tasks CreateItem(Path.Combine("wwwroot","sample.js"), new Dictionary { ["BasePath"] = "MyLibrary", + ["SourceId"] = "MyLibrary", ["ContentRoot"] = Path.Combine(".", "MyLibrary") }), CreateItem(Path.Combine("wwwroot", "otherLib.js"), new Dictionary { ["BasePath"] = "MyOtherLibrary", + ["SourceId"] = "MyOtherLibrary", ["ContentRoot"] = Path.Combine(".", "MyLibrary") }) } @@ -191,7 +247,7 @@ namespace Microsoft.AspNetCore.Razor.Tasks // Arrange var file = Path.GetTempFileName(); var expectedDocument = $@" - + "; try @@ -206,7 +262,8 @@ namespace Microsoft.AspNetCore.Razor.Tasks CreateItem(Path.Combine("wwwroot","sample.js"), new Dictionary { ["BasePath"] = "MyLibrary", - ["ContentRoot"] = Path.Combine(".", "nuget", "MyLibrary", "razorContent") + ["ContentRoot"] = Path.Combine(".", "nuget", "MyLibrary", "razorContent"), + ["SourceId"] = "MyLibrary" }), }, TargetManifestPath = file @@ -235,7 +292,7 @@ namespace Microsoft.AspNetCore.Razor.Tasks // Arrange var file = Path.GetTempFileName(); var expectedDocument = $@" - + "; try @@ -251,12 +308,14 @@ namespace Microsoft.AspNetCore.Razor.Tasks { // Base path needs to be normalized to '/' as it goes in the url ["BasePath"] = "Base\\MyLibrary", + ["SourceId"] = "MyLibrary", ["ContentRoot"] = Path.Combine(".", "nuget", "MyLibrary", "razorContent") }), // Comparisons are case insensitive CreateItem(Path.Combine("wwwroot, site.css"), new Dictionary { ["BasePath"] = "Base\\MyLIBRARY", + ["SourceId"] = "MyLibrary", ["ContentRoot"] = Path.Combine(".", "nuget", "MyLIBRARY", "razorContent") }), }, diff --git a/src/Razor/Microsoft.NET.Sdk.Razor/test/ValidateStaticWebAssetsUniquePathsTest.cs b/src/Razor/Microsoft.NET.Sdk.Razor/test/ValidateStaticWebAssetsUniquePathsTest.cs new file mode 100644 index 0000000000..a1a7a09337 --- /dev/null +++ b/src/Razor/Microsoft.NET.Sdk.Razor/test/ValidateStaticWebAssetsUniquePathsTest.cs @@ -0,0 +1,141 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.IO; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.Razor.Tasks +{ + public class ValidateStaticWebAssetsUniquePathsTest + { + [Fact] + public void ReturnsError_WhenStaticWebAssetsWebRootPathMatchesExistingContentItemPath() + { + // Arrange + var errorMessages = new List(); + var buildEngine = new Mock(); + buildEngine.Setup(e => e.LogErrorEvent(It.IsAny())) + .Callback(args => errorMessages.Add(args.Message)); + + var task = new ValidateStaticWebAssetsUniquePaths + { + BuildEngine = buildEngine.Object, + StaticWebAssets = new TaskItem[] + { + CreateItem(Path.Combine(".", "Library", "wwwroot", "sample.js"), new Dictionary + { + ["BasePath"] = "/", + ["RelativePath"] = "/sample.js", + }) + }, + WebRootFiles = new TaskItem[] + { + CreateItem(Path.Combine(".", "App", "wwwroot", "sample.js"), new Dictionary + { + ["TargetPath"] = "/SAMPLE.js", + }) + } + }; + + // Act + var result = task.Execute(); + + // Assert + Assert.False(result); + var message = Assert.Single(errorMessages); + Assert.Equal($"The static web asset '{Path.Combine(".", "Library", "wwwroot", "sample.js")}' has a conflicting web root path '/SAMPLE.js' with the project file '{Path.Combine(".", "App", "wwwroot", "sample.js")}'.", message); + } + + [Fact] + public void ReturnsError_WhenMultipleStaticWebAssetsHaveTheSameWebRootPath() + { + // Arrange + var errorMessages = new List(); + var buildEngine = new Mock(); + buildEngine.Setup(e => e.LogErrorEvent(It.IsAny())) + .Callback(args => errorMessages.Add(args.Message)); + + var task = new ValidateStaticWebAssetsUniquePaths + { + BuildEngine = buildEngine.Object, + StaticWebAssets = new TaskItem[] + { + CreateItem(Path.Combine(".", "Library", "wwwroot", "sample.js"), new Dictionary + { + ["BasePath"] = "/", + ["RelativePath"] = "/sample.js", + }), + CreateItem(Path.Combine(".", "Library", "bin", "dist", "sample.js"), new Dictionary + { + ["BasePath"] = "/", + ["RelativePath"] = "/sample.js", + }) + } + }; + + // Act + var result = task.Execute(); + + // Assert + Assert.False(result); + var message = Assert.Single(errorMessages); + Assert.Equal($"Conflicting assets with the same path '/sample.js' for content root paths '{Path.Combine(".", "Library", "bin", "dist", "sample.js")}' and '{Path.Combine(".", "Library", "wwwroot", "sample.js")}'.", message); + } + + [Fact] + public void ReturnsSuccess_WhenStaticWebAssetsDontConflictWithApplicationContentItems() + { + // Arrange + var errorMessages = new List(); + var buildEngine = new Mock(); + + var task = new ValidateStaticWebAssetsUniquePaths + { + BuildEngine = buildEngine.Object, + StaticWebAssets = new TaskItem[] + { + CreateItem(Path.Combine(".", "Library", "wwwroot", "sample.js"), new Dictionary + { + ["BasePath"] = "/_library", + ["RelativePath"] = "/sample.js", + }), + CreateItem(Path.Combine(".", "Library", "wwwroot", "sample.js"), new Dictionary + { + ["BasePath"] = "/_library", + ["RelativePath"] = "/sample.js", + }) + }, + WebRootFiles = new TaskItem[] + { + CreateItem(Path.Combine(".", "App", "wwwroot", "sample.js"), new Dictionary + { + ["TargetPath"] = "/SAMPLE.js", + }) + } + }; + + // Act + var result = task.Execute(); + + // Assert + Assert.True(result); + } + + private static TaskItem CreateItem( + string spec, + IDictionary metadata) + { + var result = new TaskItem(spec); + foreach (var (key, value) in metadata) + { + result.SetMetadata(key, value); + } + + return result; + } + } +}