Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,11 @@ Copyright (c) .NET Foundation. All rights reserved.
AssemblyFile="$(StaticWebAssetsSdkBuildTasksAssembly)"
Condition="'$(StaticWebAssetsSdkBuildTasksAssembly)' != ''" />

<UsingTask
TaskName="Microsoft.AspNetCore.StaticWebAssets.Tasks.ParseAdditionalStaticWebAssetsBasePaths"
AssemblyFile="$(StaticWebAssetsSdkBuildTasksAssembly)"
Condition="'$(StaticWebAssetsSdkBuildTasksAssembly)' != ''" />

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot don't do this with a custom task.

Use an itemgroup with Include then from that item group, create a separate item group with a transform.

Something like

<ItemGroup>
  <MyItem Include="$(MyItems)">
    <Path>$([System.String]::Copy(%(Identity)).Split(',')[0])</Path>
    <BasePath>$([System.String]::Copy(%(Identity)).Split(',')[1])</BasePath>
  </MyItem>
</ItemGroup>

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replaced the custom task with MSBuild ItemGroup transforms in 76775fb. The implementation now uses property functions to split the semicolon-separated list into items and extract ContentRoot/BasePath using Split(','), followed by a glob pattern to discover files.

<!-- Static web assets define how web content needs to be served from web applications, class libraries and packages during
development and what and where web contents need to be served on the published application.

Expand Down Expand Up @@ -304,6 +309,7 @@ Copyright (c) .NET Foundation. All rights reserved.
<ResolveCoreStaticWebAssetsDependsOn>
UpdateExistingPackageStaticWebAssets;
ResolveProjectStaticWebAssets;
ResolveAdditionalStaticWebAssets;
ResolveEmbeddedProjectsStaticWebAssets;
ResolveReferencedProjectsStaticWebAssets;
$(ResolveCoreStaticWebAssetsDependsOn);
Expand Down Expand Up @@ -719,6 +725,51 @@ Copyright (c) .NET Foundation. All rights reserved.

</Target>

<!--
ResolveAdditionalStaticWebAssets processes additional content roots passed via the AdditionalStaticWebAssetsBasePath property.
The property is a semicolon separated list of values where each value is a content root path followed by a base path separated by comma.
For example: <<content-root-1>>,<<base-path-1>>;<<content-root-2>>,<<base-path-2>>
-->
<Target Name="ResolveAdditionalStaticWebAssets"
Condition="'$(NoBuild)' != 'true' and '$(AdditionalStaticWebAssetsBasePath)' != ''"
DependsOnTargets="ResolveStaticWebAssetsConfiguration">

<ParseAdditionalStaticWebAssetsBasePaths
AdditionalStaticWebAssetsBasePaths="$(AdditionalStaticWebAssetsBasePath)"
>
<Output TaskParameter="ParsedBasePaths" ItemName="_ParsedAdditionalContentRoot" />
<Output TaskParameter="DiscoveredFiles" ItemName="_AdditionalContentRootCandidates" />
</ParseAdditionalStaticWebAssetsBasePaths>

<DefineStaticWebAssets
CandidateAssets="@(_AdditionalContentRootCandidates)"
FingerprintCandidates="$(StaticWebAssetsFingerprintContent)"
FingerprintPatterns="@(StaticWebAssetFingerprintPattern)"
SourceType="Discovered"
SourceId="$(PackageId)"
AssetMergeSource="$(StaticWebAssetMergeTarget)">
<Output TaskParameter="Assets" ItemName="StaticWebAsset" />
<Output TaskParameter="Assets" ItemName="_AdditionalStaticWebAsset" />
</DefineStaticWebAssets>

<DefineStaticWebAssetEndpoints
CandidateAssets="@(_AdditionalStaticWebAsset)"
ContentTypeMappings="@(StaticWebAssetContentTypeMapping)"
>
<Output TaskParameter="Endpoints" ItemName="StaticWebAssetEndpoint" />
</DefineStaticWebAssetEndpoints>

<ItemGroup>
<StaticWebAssetDiscoveryPattern Include="$(PackageId)\AdditionalContentRoot\%(_ParsedAdditionalContentRoot.Identity)" Condition="Exists('%(_ParsedAdditionalContentRoot.ContentRoot)')">
<Source>$(PackageId)</Source>
<BasePath>%(_ParsedAdditionalContentRoot.BasePath)</BasePath>
<ContentRoot>%(_ParsedAdditionalContentRoot.ContentRoot)</ContentRoot>
<Pattern>**</Pattern>
</StaticWebAssetDiscoveryPattern>
</ItemGroup>

</Target>

<Target Name="UpdateExistingPackageStaticWebAssets">
<UpdatePackageStaticWebAssets Assets="@(StaticWebAsset)">
<Output TaskParameter="UpdatedAssets" ItemName="_UpdatedPackageAssets" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

#nullable disable

using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;

namespace Microsoft.AspNetCore.StaticWebAssets.Tasks;

/// <summary>
/// Parses the AdditionalStaticWebAssetsBasePath property which is a semicolon-separated list of
/// content-root,base-path pairs and discovers the files in each content root.
/// </summary>
public class ParseAdditionalStaticWebAssetsBasePaths : Task
{
/// <summary>
/// The semicolon-separated list of content-root,base-path pairs.
/// Format: content-root-1,base-path-1;content-root-2,base-path-2
/// </summary>
[Required]
public string AdditionalStaticWebAssetsBasePaths { get; set; }

/// <summary>
/// The parsed content root and base path pairs as items with ContentRoot and BasePath metadata.
/// </summary>
[Output]
public ITaskItem[] ParsedBasePaths { get; set; }

/// <summary>
/// The discovered files from all content roots with appropriate metadata for DefineStaticWebAssets.
/// </summary>
[Output]
public ITaskItem[] DiscoveredFiles { get; set; }

public override bool Execute()
{
var parsedPaths = new List<ITaskItem>();
var discoveredFiles = new List<ITaskItem>();

if (string.IsNullOrEmpty(AdditionalStaticWebAssetsBasePaths))
{
ParsedBasePaths = [];
DiscoveredFiles = [];
return true;
}

var pairs = AdditionalStaticWebAssetsBasePaths.Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries);

foreach (var pair in pairs)
{
var parts = pair.Split(new[] { ',' }, 2, StringSplitOptions.None);

if (parts.Length < 2)
{
Log.LogError(
"Invalid format for AdditionalStaticWebAssetsBasePath entry '{0}'. Expected format: 'content-root,base-path'.",
pair);
continue;
}

var contentRoot = parts[0].Trim();
var basePath = parts[1].Trim();

if (string.IsNullOrEmpty(contentRoot))
{
Log.LogError(
"Content root is empty in AdditionalStaticWebAssetsBasePath entry '{0}'.",
pair);
continue;
}

// Normalize content root path to end with directory separator
if (!contentRoot.EndsWith(Path.DirectorySeparatorChar.ToString()) &&
!contentRoot.EndsWith(Path.AltDirectorySeparatorChar.ToString()))
{
contentRoot += Path.DirectorySeparatorChar;
}

// Make content root path absolute if it's relative
if (!Path.IsPathRooted(contentRoot))
{
contentRoot = Path.GetFullPath(contentRoot);
}

var pathItem = new TaskItem(parsedPaths.Count.ToString(System.Globalization.CultureInfo.InvariantCulture));
pathItem.SetMetadata("ContentRoot", contentRoot);
pathItem.SetMetadata("BasePath", basePath);

Log.LogMessage(MessageImportance.Low,
"Parsed additional static web asset base path: ContentRoot='{0}', BasePath='{1}'",
contentRoot,
basePath);

parsedPaths.Add(pathItem);

// Discover files from this content root
if (Directory.Exists(contentRoot))
{
var files = Directory.GetFiles(contentRoot, "*", SearchOption.AllDirectories);
foreach (var file in files)
{
var fullPath = Path.GetFullPath(file);
var relativePath = fullPath.Substring(contentRoot.Length);

// Normalize path separators
relativePath = relativePath.Replace(Path.DirectorySeparatorChar, '/');

var fileItem = new TaskItem(fullPath);
fileItem.SetMetadata("ContentRoot", contentRoot);
fileItem.SetMetadata("BasePath", basePath);
fileItem.SetMetadata("RelativePath", relativePath);

Log.LogMessage(MessageImportance.Low,
"Discovered file '{0}' with relative path '{1}' in content root '{2}'",
fullPath,
relativePath,
contentRoot);

discoveredFiles.Add(fileItem);
}
}
else
{
Log.LogWarning(
"Content root directory '{0}' does not exist.",
contentRoot);
}
}

ParsedBasePaths = [.. parsedPaths];
DiscoveredFiles = [.. discoveredFiles];

return !Log.HasLoggedErrors;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -617,6 +617,153 @@ public void Build_DoesNotFailToCompress_TwoAssetsWith_TheSameContent()
AssertManifest(manifest1, expectedManifest);
AssertBuildAssets(manifest1, outputPath, intermediateOutputPath);
}

[Fact]
public void Build_AdditionalStaticWebAssetsBasePath_IncludesExternalContentRoots()
{
var testAsset = "RazorComponentApp";
ProjectDirectory = CreateAspNetSdkTestAsset(testAsset);

// Create an additional folder outside wwwroot to serve as additional content root
var additionalContentRoot = Path.Combine(ProjectDirectory.Path, "AdditionalAssets");
Directory.CreateDirectory(additionalContentRoot);
File.WriteAllText(Path.Combine(additionalContentRoot, "extra.js"), "console.log('extra');");
File.WriteAllText(Path.Combine(additionalContentRoot, "extra.css"), ".extra { color: red; }");

// Create a subdirectory with more assets
var subDir = Path.Combine(additionalContentRoot, "sub");
Directory.CreateDirectory(subDir);
File.WriteAllText(Path.Combine(subDir, "nested.txt"), "nested content");

var build = CreateBuildCommand(ProjectDirectory);

// Pass the AdditionalStaticWebAssetsBasePath property with format: content-root,base-path
// Quote the entire value to prevent MSBuild from interpreting the comma as a separator
var additionalAssetsProperty = $"\"{additionalContentRoot},_content/AdditionalAssets\"";
ExecuteCommand(build, $"/p:AdditionalStaticWebAssetsBasePath={additionalAssetsProperty}").Should().Pass();

var intermediateOutputPath = build.GetIntermediateDirectory(DefaultTfm, "Debug").ToString();
var outputPath = build.GetOutputDirectory(DefaultTfm, "Debug").ToString();

// Verify the manifest file exists
var path = Path.Combine(intermediateOutputPath, "staticwebassets.build.json");
new FileInfo(path).Should().Exist();

var manifest = StaticWebAssetsManifest.FromJsonBytes(File.ReadAllBytes(path));

// Verify the additional assets are in the manifest (only count Primary assets, not compressed alternatives)
var additionalAssets = manifest.Assets.Where(a =>
a.AssetRole == "Primary" &&
(a.RelativePath.Contains("extra.js") ||
a.RelativePath.Contains("extra.css") ||
a.RelativePath.Contains("nested.txt"))).ToArray();

additionalAssets.Should().HaveCount(3, "All additional assets should be included in the manifest");

// Verify the base path is correct
foreach (var asset in additionalAssets)
{
asset.BasePath.Should().Be("_content/AdditionalAssets");
}

// Verify discovery patterns include the additional content root
var discoveryPattern = manifest.DiscoveryPatterns.FirstOrDefault(p =>
p.BasePath == "_content/AdditionalAssets");
discoveryPattern.Should().NotBeNull("Discovery pattern for additional content root should exist");
}

[Fact]
public void Build_AdditionalStaticWebAssetsBasePath_MultipleContentRoots()
{
var testAsset = "RazorComponentApp";
ProjectDirectory = CreateAspNetSdkTestAsset(testAsset);

// Create first additional folder
var additionalContentRoot1 = Path.Combine(ProjectDirectory.Path, "AdditionalAssets1");
Directory.CreateDirectory(additionalContentRoot1);
File.WriteAllText(Path.Combine(additionalContentRoot1, "file1.js"), "console.log('file1');");

// Create second additional folder
var additionalContentRoot2 = Path.Combine(ProjectDirectory.Path, "AdditionalAssets2");
Directory.CreateDirectory(additionalContentRoot2);
File.WriteAllText(Path.Combine(additionalContentRoot2, "file2.js"), "console.log('file2');");

var build = CreateBuildCommand(ProjectDirectory);

// Pass multiple content roots separated by semicolons
// Quote the entire value to prevent MSBuild from interpreting commas/semicolons as separators
var additionalAssetsProperty = $"\"{additionalContentRoot1},_content/Assets1;{additionalContentRoot2},_content/Assets2\"";
ExecuteCommand(build, $"/p:AdditionalStaticWebAssetsBasePath={additionalAssetsProperty}").Should().Pass();

var intermediateOutputPath = build.GetIntermediateDirectory(DefaultTfm, "Debug").ToString();

var path = Path.Combine(intermediateOutputPath, "staticwebassets.build.json");
new FileInfo(path).Should().Exist();

var manifest = StaticWebAssetsManifest.FromJsonBytes(File.ReadAllBytes(path));

// Verify assets from both content roots are in the manifest (only count Primary assets)
var asset1 = manifest.Assets.FirstOrDefault(a => a.AssetRole == "Primary" && a.RelativePath.Contains("file1.js"));
var asset2 = manifest.Assets.FirstOrDefault(a => a.AssetRole == "Primary" && a.RelativePath.Contains("file2.js"));

asset1.Should().NotBeNull("Asset from first additional content root should exist");
asset1!.BasePath.Should().Be("_content/Assets1");

asset2.Should().NotBeNull("Asset from second additional content root should exist");
asset2!.BasePath.Should().Be("_content/Assets2");

// Verify discovery patterns for both content roots
manifest.DiscoveryPatterns.Should().Contain(p => p.BasePath == "_content/Assets1");
manifest.DiscoveryPatterns.Should().Contain(p => p.BasePath == "_content/Assets2");
}

[Fact]
public void Publish_AdditionalStaticWebAssetsBasePath_IncludesExternalContentRoots()
{
var testAsset = "RazorComponentApp";
ProjectDirectory = CreateAspNetSdkTestAsset(testAsset);

// Create an additional folder outside wwwroot to serve as additional content root
var additionalContentRoot = Path.Combine(ProjectDirectory.Path, "AdditionalAssets");
Directory.CreateDirectory(additionalContentRoot);
File.WriteAllText(Path.Combine(additionalContentRoot, "publish-extra.js"), "console.log('publish-extra');");

var publish = CreatePublishCommand(ProjectDirectory);

// Pass the AdditionalStaticWebAssetsBasePath property
// Quote the entire value to prevent MSBuild from interpreting the comma as a separator
var additionalAssetsProperty = $"\"{additionalContentRoot},_content/AdditionalAssets\"";
ExecuteCommand(publish, $"/p:AdditionalStaticWebAssetsBasePath={additionalAssetsProperty}").Should().Pass();

var intermediateOutputPath = publish.GetIntermediateDirectory(DefaultTfm, "Debug").ToString();
var publishPath = publish.GetOutputDirectory(DefaultTfm, "Debug").ToString();

// Verify the build manifest file exists and includes the additional assets
var buildPath = Path.Combine(intermediateOutputPath, "staticwebassets.build.json");
new FileInfo(buildPath).Should().Exist();

var buildManifest = StaticWebAssetsManifest.FromJsonBytes(File.ReadAllBytes(buildPath));

// Verify the additional asset is in the build manifest (only count Primary assets)
var additionalAsset = buildManifest.Assets.FirstOrDefault(a =>
a.AssetRole == "Primary" && a.RelativePath.Contains("publish-extra.js"));

additionalAsset.Should().NotBeNull("Additional asset should be included in the build manifest");
additionalAsset!.BasePath.Should().Be("_content/AdditionalAssets");

// Verify the publish manifest file exists
var publishManifestPath = Path.Combine(intermediateOutputPath, "staticwebassets.publish.json");
new FileInfo(publishManifestPath).Should().Exist();

var publishManifest = StaticWebAssetsManifest.FromJsonBytes(File.ReadAllBytes(publishManifestPath));

// Verify the additional asset is in the publish manifest
var publishAsset = publishManifest.Assets.FirstOrDefault(a =>
a.AssetRole == "Primary" && a.RelativePath.Contains("publish-extra.js"));

publishAsset.Should().NotBeNull("Additional asset should be included in the publish manifest");
publishAsset!.BasePath.Should().Be("_content/AdditionalAssets");
}
}

public class StaticWebAssetsAppWithPackagesIntegrationTest(ITestOutputHelper log)
Expand Down
Loading