[StaticWebAssets] Updates manifest generation to allow multiple content

roots under the same base path for a given project.
\n\nCommit migrated from 5e68cb88e4
This commit is contained in:
Javier Calvarro Nelson 2019-11-26 04:52:46 -08:00
parent 9328e4723e
commit 541323631f
5 changed files with 336 additions and 15 deletions

View File

@ -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
{

View File

@ -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<string, ITaskItem>(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;
}
}
}

View File

@ -32,6 +32,11 @@ Copyright (c) .NET Foundation. All rights reserved.
AssemblyFile="$(RazorSdkBuildTasksAssembly)"
Condition="'$(RazorSdkBuildTasksAssembly)' != ''" />
<UsingTask
TaskName="Microsoft.AspNetCore.Razor.Tasks.ValidateStaticWebAssetsUniquePaths"
AssemblyFile="$(RazorSdkBuildTasksAssembly)"
Condition="'$(RazorSdkBuildTasksAssembly)' != ''" />
<UsingTask
TaskName="Microsoft.AspNetCore.Razor.Tasks.GenerateStaticWebAsssetsPropsFile"
AssemblyFile="$(RazorSdkBuildTasksAssembly)"
@ -145,11 +150,12 @@ Copyright (c) .NET Foundation. All rights reserved.
Condition="'%(SourceType)' != ''">
<BasePath>%(StaticWebAsset.BasePath)</BasePath>
<ContentRoot>%(StaticWebAsset.ContentRoot)</ContentRoot>
<SourceId>%(StaticWebAsset.SourceId)</SourceId>
</_ExternalStaticWebAsset>
</ItemGroup>
<!-- We need a transform here to make sure we hash the metadata -->
<Hash ItemsToHash="@(_ExternalStaticWebAsset->'%(Identity)%(BasePath)%(ContentRoot)')">
<Hash ItemsToHash="@(_ExternalStaticWebAsset->'%(Identity)%(SourceId)%(BasePath)%(ContentRoot)')">
<Output TaskParameter="HashResult" PropertyName="_StaticWebAssetsCacheHash" />
</Hash>
@ -181,6 +187,15 @@ Copyright (c) .NET Foundation. All rights reserved.
Outputs="$(_GeneratedStaticWebAssetsDevelopmentManifest)"
DependsOnTargets="$(GenerateStaticWebAssetsManifestDependsOn)">
<ItemGroup>
<_WebRootFiles Include="@(ContentWithTargetPath)" Condition="$([System.String]::Copy('%(TargetPath)').Replace('\','/').StartsWith('wwwroot/'))" />
<_ReferencedStaticWebAssets Include="@(StaticWebAsset)" Condition="'%(SourceType)' != ''" />
</ItemGroup>
<ValidateStaticWebAssetsUniquePaths
StaticWebAssets="@(_ReferencedStaticWebAssets)"
WebRootFiles="@(_WebRootFiles)" />
<GenerateStaticWebAssetsManifest
ContentRootDefinitions="@(_ExternalStaticWebAsset)"
TargetManifestPath="$(_GeneratedStaticWebAssetsDevelopmentManifest)" />
@ -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/'))">
<!-- Remove the wwwroot\ prefix -->
<RelativePath>$([System.String]::Copy('%(Identity)').Substring(8))</RelativePath>

View File

@ -89,12 +89,14 @@ namespace Microsoft.AspNetCore.Razor.Tasks
CreateItem(Path.Combine("wwwroot","sample.js"), new Dictionary<string,string>
{
["BasePath"] = "MyLibrary",
["ContentRoot"] = Path.Combine("nuget","MyLibrary")
["ContentRoot"] = Path.Combine("nuget", "MyLibrary"),
["SourceId"] = "MyLibrary"
}),
CreateItem(Path.Combine("wwwroot", "otherLib.js"), new Dictionary<string,string>
{
["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 = $@"<StaticWebAssets Version=""1.0"">
<ContentRoot BasePath=""Blazor.Client"" Path=""{Path.Combine(".", "nuget", $"Blazor.Client{Path.DirectorySeparatorChar}")}"" />
<ContentRoot BasePath=""Blazor.Client"" Path=""{Path.Combine(".", "nuget", "bin", "debug", $"netstandard2.1{Path.DirectorySeparatorChar}")}"" />
</StaticWebAssets>";
var buildEngine = new Mock<IBuildEngine>();
var task = new GenerateStaticWebAssetsManifest
{
BuildEngine = buildEngine.Object,
ContentRootDefinitions = new TaskItem[]
{
CreateItem(Path.Combine("wwwroot","sample.js"), new Dictionary<string,string>
{
["BasePath"] = "Blazor.Client",
["ContentRoot"] = Path.Combine(".", "nuget","Blazor.Client"),
["SourceId"] = "Blazor.Client"
}),
CreateItem(Path.Combine("wwwroot", "otherLib.js"), new Dictionary<string,string>
{
["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<string,string>
{
["BasePath"] = "MyLibrary",
["SourceId"] = "MyLibrary",
["ContentRoot"] = Path.Combine(".", "MyLibrary")
}),
CreateItem(Path.Combine("wwwroot", "otherLib.js"), new Dictionary<string,string>
{
["BasePath"] = "MyOtherLibrary",
["SourceId"] = "MyOtherLibrary",
["ContentRoot"] = Path.Combine(".", "MyLibrary")
})
}
@ -191,7 +247,7 @@ namespace Microsoft.AspNetCore.Razor.Tasks
// Arrange
var file = Path.GetTempFileName();
var expectedDocument = $@"<StaticWebAssets Version=""1.0"">
<ContentRoot BasePath=""MyLibrary"" Path=""{Path.Combine(".", "nuget", "MyLibrary", "razorContent")}"" />
<ContentRoot BasePath=""MyLibrary"" Path=""{Path.Combine(".", "nuget", "MyLibrary", $"razorContent{Path.DirectorySeparatorChar}")}"" />
</StaticWebAssets>";
try
@ -206,7 +262,8 @@ namespace Microsoft.AspNetCore.Razor.Tasks
CreateItem(Path.Combine("wwwroot","sample.js"), new Dictionary<string,string>
{
["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 = $@"<StaticWebAssets Version=""1.0"">
<ContentRoot BasePath=""Base/MyLibrary"" Path=""{Path.Combine(".", "nuget", "MyLibrary", "razorContent")}"" />
<ContentRoot BasePath=""Base/MyLibrary"" Path=""{Path.Combine(".", "nuget", "MyLibrary", $"razorContent{Path.DirectorySeparatorChar}")}"" />
</StaticWebAssets>";
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<string,string>
{
["BasePath"] = "Base\\MyLIBRARY",
["SourceId"] = "MyLibrary",
["ContentRoot"] = Path.Combine(".", "nuget", "MyLIBRARY", "razorContent")
}),
},

View File

@ -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<string>();
var buildEngine = new Mock<IBuildEngine>();
buildEngine.Setup(e => e.LogErrorEvent(It.IsAny<BuildErrorEventArgs>()))
.Callback<BuildErrorEventArgs>(args => errorMessages.Add(args.Message));
var task = new ValidateStaticWebAssetsUniquePaths
{
BuildEngine = buildEngine.Object,
StaticWebAssets = new TaskItem[]
{
CreateItem(Path.Combine(".", "Library", "wwwroot", "sample.js"), new Dictionary<string,string>
{
["BasePath"] = "/",
["RelativePath"] = "/sample.js",
})
},
WebRootFiles = new TaskItem[]
{
CreateItem(Path.Combine(".", "App", "wwwroot", "sample.js"), new Dictionary<string,string>
{
["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<string>();
var buildEngine = new Mock<IBuildEngine>();
buildEngine.Setup(e => e.LogErrorEvent(It.IsAny<BuildErrorEventArgs>()))
.Callback<BuildErrorEventArgs>(args => errorMessages.Add(args.Message));
var task = new ValidateStaticWebAssetsUniquePaths
{
BuildEngine = buildEngine.Object,
StaticWebAssets = new TaskItem[]
{
CreateItem(Path.Combine(".", "Library", "wwwroot", "sample.js"), new Dictionary<string,string>
{
["BasePath"] = "/",
["RelativePath"] = "/sample.js",
}),
CreateItem(Path.Combine(".", "Library", "bin", "dist", "sample.js"), new Dictionary<string,string>
{
["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<string>();
var buildEngine = new Mock<IBuildEngine>();
var task = new ValidateStaticWebAssetsUniquePaths
{
BuildEngine = buildEngine.Object,
StaticWebAssets = new TaskItem[]
{
CreateItem(Path.Combine(".", "Library", "wwwroot", "sample.js"), new Dictionary<string,string>
{
["BasePath"] = "/_library",
["RelativePath"] = "/sample.js",
}),
CreateItem(Path.Combine(".", "Library", "wwwroot", "sample.js"), new Dictionary<string,string>
{
["BasePath"] = "/_library",
["RelativePath"] = "/sample.js",
})
},
WebRootFiles = new TaskItem[]
{
CreateItem(Path.Combine(".", "App", "wwwroot", "sample.js"), new Dictionary<string,string>
{
["TargetPath"] = "/SAMPLE.js",
})
}
};
// Act
var result = task.Execute();
// Assert
Assert.True(result);
}
private static TaskItem CreateItem(
string spec,
IDictionary<string, string> metadata)
{
var result = new TaskItem(spec);
foreach (var (key, value) in metadata)
{
result.SetMetadata(key, value);
}
return result;
}
}
}