Skip to content

Add Asset Groups support for static web assets#53187

Open
javiercn wants to merge 19 commits intomainfrom
javiercn/asset-groups
Open

Add Asset Groups support for static web assets#53187
javiercn wants to merge 19 commits intomainfrom
javiercn/asset-groups

Conversation

@javiercn
Copy link
Member

@javiercn javiercn commented Feb 27, 2026

Summary

This document proposes adding Static Web Asset Groups to the .NET SDK to let libraries ship multiple variants of the same static asset (CSS, JS, etc.) and let consumers declaratively select which variant they want — without writing custom MSBuild targets.

The feature is motivated by two projects in dotnet/aspnetcore that currently bypass the standard Static Web Assets pipeline with hand-written MSBuild logic. Identity UI ships Bootstrap V4 and V5 CSS/JS under separate assets/V4/ and assets/V5/ directories, using a custom GetIdentityUIAssets target and per-variant XML props files to let consumers pick a version. Microsoft.AspNetCore.App.Internal.Assets ships Blazor framework JS files (blazor.web.js, blazor.server.js) and uses a custom target to inject them only for appropriate project types, packaging them outside the standard SWA pipeline entirely. Both projects bypass the standard pipeline with hand-written MSBuild targets that introduce less tested code paths — paths that repeatedly break when the SDK evolves and that behave in ways the pipeline does not expect.

Asset Groups introduces two MSBuild item types: StaticWebAssetGroupDefinition (declared by the library to tag assets with named groups) and StaticWebAssetGroup (declared by the consumer to select which group values to include). The SDK handles tagging, filtering, path rewriting, pack/restore round-tripping, and endpoint generation — including a deferred resolution mode for groups whose value depends on consumer build state. Libraries can ship a convention file (StaticWebAssets.Groups.targets) that maps a simple consumer property to the appropriate group selection, so consumers only set a single <PropertyGroup> property.

Goals

  • Enable libraries to declare asset variants and consumers to select among them declaratively. Today, Identity UI uses a custom GetIdentityUIAssets target with StaticWebAssetsGetBuildAssetsTargets to select V4 or V5 assets based on IdentityDefaultUIFramework, and a separate _GenerateIdentityUIPackItems target to manually generate per-variant XML props files and compute target paths. Assets.Internal uses _AddBlazorFrameworkStaticWebAssets to inject Blazor JS files outside the standard pipeline. Both require hand-written MSBuild that is under-tested and breaks when the SDK evolves. Libraries should instead declare StaticWebAssetGroupDefinition items to tag assets with named groups, and consumers should declare StaticWebAssetGroup items (or just set a property) to select which values to include — with the SDK handling all the plumbing.

  • Support path rewriting so variant selection is transparent to consumers. Identity UI serves assets under /Identity/css/site.css regardless of whether Bootstrap V4 or V5 is selected. The variant directory prefix (V4/, V5/) is an organizational concern on disk but must not appear in endpoint routes. The feature must support stripping a prefix from the RelativePath via a RelativePathPattern and replacing it with a file-only group token (#[{GroupName}]~/) via RelativePathPrefix, so the token participates in locating the file on disk but is invisible in the HTTP route.

  • Support deferred group resolution for groups that depend on consumer build state. Assets.Internal currently checks OutputType and UsingMicrosoftNETSdkWeb to decide which Blazor JS files to include. In the new model, the group value may depend on whether the consumer references Components.Endpoints or Components.Server — information only available after reference resolution. The feature must support deferred groups: groups declared with Deferred="true" that keep all variants alive through package restore and P2P resolution, with a custom resolve target running before the final filtering pass to set the concrete value.

  • Preserve groups through the pack/restore round-trip. AssetGroups metadata on assets must survive packaging into a NuGet package (in the JSON manifest) and be available for filtering when the consumer restores the package. The StaticWebAssets.Groups.targets convention file must also be packed and automatically imported by the consumer.

  • Eliminate custom MSBuild in aspnetcore for asset variant selection. The GetIdentityUIAssets target (with its StaticWebAssetsGetBuildAssetsTargets hook), _GenerateIdentityUIPackItems target (with per-variant GenerateStaticWebAssetsPropsFile / GenerateStaticWebAssetEndpointsPropsFile / ComputeStaticWebAssetsTargetPaths calls and per-variant StaticWebAssetPackageFile items), the conditional .props import chain (Microsoft.AspNetCore.StaticWebAssets.V4.targets / V5.targets), and the _AddBlazorFrameworkStaticWebAssets target should all be replaceable by standard SDK group declarations and the convention file.

Non-goals

  • Changing how assets flow through project references at the target level. The existing GetCurrentProjectBuildStaticWebAssetItems pattern for P2P asset resolution is unchanged. Group filtering for P2P references is handled by UpdateExternallyDefinedStaticWebAssets with SkipDeferred=true at the reference boundary and FilterStaticWebAssetGroups at the final build step — but the target-call mechanism itself is not modified.

  • Supporting dynamic group selection at runtime. Groups are resolved at build time. There is no mechanism to select a group value at application startup or request time. The consumer's group selection is a compile-time decision.

  • Removing the existing XML .props packaging path. The old GenerateStaticWebAssetsPropsFile / GenerateStaticWebAssetEndpointsPropsFile tasks and their .props import chain are preserved for packages targeting .NET 10 or earlier. This spec introduces groups as a feature that works with both the old and new packaging formats, though the JSON manifest path provides ahead-of-time filtering benefits.

Proposed solution

The solution introduces a producer/consumer model for asset variant selection. Libraries define which assets belong to which group variants. Consumers select which variant they want. The SDK handles the rest: tagging assets during discovery, filtering them before endpoint generation, preserving group metadata through pack/restore, and supporting deferred resolution for late-bound decisions.

Two new MSBuild item types drive the feature. On the producer side, StaticWebAssetGroupDefinition items tag assets with named group values during DefineStaticWebAssets. Each definition requires four metadata fields: Value (the group variant value), SourceId (scoping key matching the producing project or package), Order (integer controlling evaluation order when multiple definitions apply), and IncludePattern (glob matching which assets belong to this variant). Missing or invalid values for any of these produce a build error at parse time. Definitions also support optional metadata: ExcludePattern, RelativePathPattern (glob extracting the stem after stripping the variant prefix), RelativePathPrefix (token expression prepended to the stem), and ContentRootSuffix (appended to ContentRoot so variants resolve to separate disk folders).

Definitions are grouped by SourceId and only evaluated against assets whose SourceId matches, so a definition declared by one library never accidentally applies to assets from a different library. When multiple definitions match the same asset (e.g., a BootstrapVersion group at Order 0 and a Theme group at Order 1), their transformations compose in Order sequence: RelativePathPrefix values concatenate (producing e.g. #[{BootstrapVersion}]~/#[{Theme}]~/), ContentRootSuffix values compose via path joining, and each definition's group Name=Value pair is appended to the asset's AssetGroups metadata. If two matching definitions specify different ContentRootSuffix values, a build error is produced — conflicting content root adjustments must be resolved explicitly.

On the consumer side, StaticWebAssetGroup items declare which value to use for each named group — the FilterStaticWebAssetGroups task matches these declarations against the AssetGroups metadata on each asset and excludes non-matching variants along with their related/alternative assets and associated endpoints.

Filtering happens at two levels. The build manifest (staticwebassets.build.json) retains all variants — it is the complete pipeline output that downstream consumers (via P2P or package reference) use to select at their own build time. The endpoints manifest (staticwebassets.build.endpoints.json) and the development manifest only include the selected variant — they reflect what the app sees at runtime.

For packages, group metadata is stored in the JSON package manifest ({PackageId}.PackageAssets.json). The GeneratePackageAssetsManifestFile task validates that every endpoint's AssetFile can be remapped to a package-relative path — if an endpoint references an asset not present in the package, the build fails instead of producing a manifest with dangling references. The ReadPackageAssetsManifest task validates that IntermediateOutputPath is set before processing (required for framework asset materialization), then applies group filtering at read time with SkipDeferred=true, so only the selected variant's assets are materialized as MSBuild items. The convention file StaticWebAssets.Groups.targets (if present in the project directory) is automatically packed into build/StaticWebAssets.Groups.targets and imported by the generated {PackageId}.targets, giving the consumer a default property and the corresponding StaticWebAssetGroup item without any manual setup.

For deferred groups, a two-pass approach keeps all variants alive through restore and P2P resolution (first pass with SkipDeferred=true), then runs custom resolve targets via the FilterDeferredStaticWebAssetGroupsDependsOn extension point before the final filtering pass (with SkipDeferred=false) that produces the endpoints manifest.

1. Consumer selects a group from a package (Identity UI pattern)

A library ships Bootstrap V4 and V5 CSS/JS variants. The consumer selects which variant to use by setting a single property.

Library (IdentityUILib) — project file:

<PropertyGroup>
  <StaticWebAssetBasePath>Identity</StaticWebAssetBasePath>
</PropertyGroup>

<ItemGroup>
  <StaticWebAssetGroupDefinition Include="BootstrapVersion" Value="V5" Order="0"
      IncludePattern="V5/**" RelativePathPattern="V5/**"
      RelativePathPrefix="#[{BootstrapVersion}]~/" ContentRootSuffix="V5" />
  <StaticWebAssetGroupDefinition Include="BootstrapVersion" Value="V4" Order="1"
      IncludePattern="V4/**" RelativePathPattern="V4/**"
      RelativePathPrefix="#[{BootstrapVersion}]~/" ContentRootSuffix="V4" />
</ItemGroup>

Library — StaticWebAssets.Groups.targets (convention file, auto-packed):

<Project>
  <PropertyGroup>
    <IdentityUIFrameworkVersion Condition="'$(IdentityUIFrameworkVersion)' == ''">V5</IdentityUIFrameworkVersion>
  </PropertyGroup>
  <ItemGroup>
    <StaticWebAssetGroup Include="BootstrapVersion"
        Value="$(IdentityUIFrameworkVersion)" SourceId="IdentityUILib" />
  </ItemGroup>
</Project>

Consumer — project file (selects V4):

<PropertyGroup>
  <IdentityUIFrameworkVersion>V4</IdentityUIFrameworkVersion>
</PropertyGroup>

Expected outcome: Only V4 assets appear in the endpoints manifest. Endpoint routes are Identity/css/site.css and Identity/js/site.js — the V4/ prefix is stripped by RelativePathPattern and the #[{BootstrapVersion}]~/ prefix resolves to V4/ on disk but is invisible in routes (the ~ modifier makes the token file-only). The consumer sees identical routes regardless of which version is selected.

Before (current aspnetcore implementation):

  • Library uses StaticWebAssetsGetBuildAssetsTargets=GetIdentityUIAssets to hook a custom target that calls DefineStaticWebAssets with a conditional ContentRoot based on IdentityDefaultUIFramework
  • Pack target _GenerateIdentityUIPackItems manually calls DefineStaticWebAssets, DefineStaticWebAssetEndpoints, GenerateStaticWebAssetsPropsFile, GenerateStaticWebAssetEndpointsPropsFile, and ComputeStaticWebAssetsTargetPaths separately for V4 and V5
  • Package ships per-variant XML files: Microsoft.AspNetCore.StaticWebAssets.V4.targets, V4.endpoints.targets, V5.targets, V5.endpoints.targets
  • Consumer-side props file conditionally imports the right pair based on IdentityUIFrameworkVersion

After (with asset groups):

  • Library declares two StaticWebAssetGroupDefinition items and ships StaticWebAssets.Groups.targets
  • Standard pack pipeline handles everything — no custom targets needed
  • Consumer sets one property; the convention file maps it to the StaticWebAssetGroup item

2. Consumer selects a group from a project reference (aspnetcore inner-repo pattern)

Inside the aspnetcore repo, projects reference Microsoft.AspNetCore.Assets.Internal via ProjectReference (with ReferenceOutputAssemblies=false). The consumer imports the referenced project's StaticWebAssets.Groups.targets manually and sets the group property.

Consumer — project file:

<ItemGroup>
  <ProjectReference Include="..\Assets\Microsoft.AspNetCore.App.Internal.Assets.csproj"
      ReferenceOutputAssemblies="false" />
</ItemGroup>

<Import Project="..\Assets\StaticWebAssets.Groups.targets" />

Expected outcome: The StaticWebAssets.Groups.targets file from the referenced project defines the StaticWebAssetGroup item. The consumer's property override (if any) selects the variant. At the P2P boundary, UpdateExternallyDefinedStaticWebAssets applies group filtering with SkipDeferred=true, passing through deferred groups and filtering resolved ones.

Before: The _AddBlazorFrameworkStaticWebAssets target in the package's build targets manually calls DefineStaticWebAssets and DefineStaticWebAssetEndpoints, gated on OutputType and UsingMicrosoftNETSdkWeb conditions. For project references within the repo, a separate mechanism (or the same targets) runs at resolve time.

After: The library declares group definitions and a StaticWebAssets.Groups.targets. The consumer imports the targets file and optionally overrides the property. The standard P2P resolution pipeline handles filtering.

3. Deferred group resolution (Blazor framework JS pattern)

Microsoft.AspNetCore.Assets.Internal ships blazor.web.js and blazor.server.js. The decision of which to include depends on whether the consumer references Components.Endpoints or Components.Server — information only available after reference resolution in the consumer project.

Library — StaticWebAssets.Groups.targets:

<Project>
  <PropertyGroup>
    <FilterDeferredStaticWebAssetGroupsDependsOn>
      $(FilterDeferredStaticWebAssetGroupsDependsOn);
      ResolveBlazorFrameworkGroup
    </FilterDeferredStaticWebAssetGroupsDependsOn>
  </PropertyGroup>

  <ItemGroup>
    <StaticWebAssetGroup Include="BlazorFramework"
        SourceId="Microsoft.AspNetCore.App.Internal.Assets" Deferred="true" />
  </ItemGroup>

  <Target Name="ResolveBlazorFrameworkGroup">
    <ItemGroup>
      <StaticWebAssetGroup Remove="BlazorFramework" />
      <StaticWebAssetGroup Include="BlazorFramework"
          Value="$(ResolvedBlazorFrameworkValue)"
          SourceId="Microsoft.AspNetCore.App.Internal.Assets" />
    </ItemGroup>
  </Target>
</Project>

Expected outcome:

  • First pass (package restore / P2P resolution, SkipDeferred=true): All Blazor JS variants pass through — the deferred group is provisionally satisfied.
  • FilterDeferredStaticWebAssetGroups runs: ResolveBlazorFrameworkGroup target executes, inspecting resolved references to determine the value, then replaces the deferred group with a concrete one.
  • Second pass (SkipDeferred=false): FilterStaticWebAssetGroups applies the now-concrete group. Only the selected JS file's assets and endpoints appear in the endpoints manifest. Non-selected variants are excluded along with their compressed alternatives and endpoints.

Before: _AddBlazorFrameworkStaticWebAssets checks OutputType and UsingMicrosoftNETSdkWeb, then calls DefineStaticWebAssets and DefineStaticWebAssetEndpoints inline. Both blazor.web.js and blazor.server.js are always included when the conditions match.

After: Both files are tagged with group metadata at pack time. The deferred resolve target picks the right one based on actual reference resolution. Only the selected file's endpoints appear in the development manifest.

4. Path rewriting hides variant prefix from endpoint routes

Assets on disk are organized under variant directories (V4/css/site.css, V5/css/site.css). The consumer sees routes without the variant prefix.

Transformation chain:

Step Value
Disk layout wwwroot/V5/css/site.css
Initial RelativePath V5/css/site.css
RelativePathPattern (V5/**) strips prefix css/site.css (stem)
RelativePathPrefix (#[{BootstrapVersion}]~/) prepends token #[{BootstrapVersion}]~/css/site.css
ContentRootSuffix (V5) adjusts content root …/wwwroot/V5/
File path resolution (~ = preferred, token resolves) V5/css/site.css → file found at …/wwwroot/V5/css/site.css
Endpoint route (~ = file-only, skipped) css/site.css
With BasePath=Identity Identity/css/site.css

The ~ modifier on the token expression is the key mechanism: it marks the segment as file-only, meaning it participates in locating the physical file but is excluded from endpoint route generation. This is what allows Identity UI to keep serving under /Identity/css/site.css regardless of which Bootstrap version is selected.

5. Cascading exclusion of related and compressed assets

When a primary asset is excluded by group filtering, its related assets (compressed alternatives like .gz and .br) and all associated endpoints are also excluded. This cascading happens inside FilterStaticWebAssetGroupsStaticWebAsset.FilterByGroup(), which sorts assets parent-before-child via SortByRelatedAssetInPlace and tracks excluded assets in a set. Any asset whose RelatedAsset points to an excluded asset is itself excluded. StaticWebAssetEndpointGroup.ComputeFilteredEndpoints() then partitions endpoints into removed vs. surviving based on whether their AssetFile was excluded.

Expected outcome: When the consumer selects V5, all V4 primary assets, their gzip/brotli compressed alternatives, and all V4 endpoints (uncompressed route, Content-Encoding selector routes, direct .gz/.br routes) are removed from the endpoints manifest. The build manifest still retains them (unfiltered).

6. Groups with no consumer selection (default exclusion)

If a package contains grouped assets and the consumer does not declare any StaticWebAssetGroup for that group name, all grouped assets are excluded. Only ungrouped assets pass through. This is because MatchesGroups() requires every Name=Value pair in the asset's AssetGroups to have a matching declaration — missing declarations mean the requirement is unsatisfied.

Expected outcome: A consumer that adds a PackageReference to a library with grouped assets but doesn't set the group property (and the library's StaticWebAssets.Groups.targets provides a default) gets the default variant. A consumer that somehow bypasses the convention file and declares no group gets no grouped assets — only ungrouped assets from the package.

7. Multiple assets at the same target path with distinct groups

When ComputeReferenceStaticWebAssetItems detects multiple assets targeting the same path, it calls AllAssetsHaveDistinctGroups(). If all conflicting assets have non-empty, distinct AssetGroups values, they are allowed to coexist — the conflict will be resolved by group filtering in the consumer. This prevents false "duplicate asset" errors for libraries that ship V4 and V5 variants mapping to the same endpoint route.

Assumptions

  • Libraries will use the StaticWebAssets.Groups.targets convention file to provide a default group value and map a consumer property to the StaticWebAssetGroup item. Without this convention file, consumers would need to manually declare StaticWebAssetGroup items — a worse experience.
  • Group names are case-sensitive. BootstrapVersion and bootstrapversion are different groups.
  • A single asset can belong to at most one group (one Name=Value pair in AssetGroups). The data model supports semicolon-separated multiple pairs, but the current scenarios only need single-group tagging.
  • Both StaticWebAssetGroupDefinition and StaticWebAssetGroup items carry a SourceId that scopes their effect. On the producer side, definitions are grouped by SourceId and only evaluated against assets from that source. On the consumer side, group declarations with a SourceId only match assets from that source. This two-level scoping prevents definitions or group selections for one library from accidentally affecting assets in another library that happens to use the same group name.
  • Deferred groups must be resolved before the final filtering pass (FilterStaticWebAssetGroups with SkipDeferred=false). Any still-deferred group at that point causes a build error.
  • The ~ (file-only) modifier on token expressions is stable and will not change semantics. It is the mechanism that makes variant prefixes invisible in endpoint routes.
  • The aspnetcore repo will adopt this feature by replacing the custom targets in Microsoft.AspNetCore.Identity.UI and Microsoft.AspNetCore.App.Internal.Assets with StaticWebAssetGroupDefinition / StaticWebAssetGroup declarations and convention files.

References

@javiercn javiercn force-pushed the javiercn/asset-groups branch from af7dca6 to 0d2f054 Compare March 13, 2026 11:37
… packaging

Introduce StaticWebAssetGroupDefinition (producer-side) and StaticWebAssetGroup
(consumer-side) item types for declarative asset variant selection.

Core changes:
- DefineStaticWebAssets: group definition processing with IncludePattern,
  RelativePathPattern, RelativePathPrefix, ContentRootSuffix
- FilterStaticWebAssetGroups: two-pass filtering (SkipDeferred for restore/P2P,
  full filtering for endpoints manifest)
- StaticWebAssetEndpointGroup: safe endpoint partitioning by AssetFile
- StaticWebAssetPathPattern: file-only '~' modifier for tokens
- StaticWebAssetTokenResolver: resolve group values from AssetGroups metadata
- ComputeReferenceStaticWebAssetItems: allow distinct groups at same path
- StaticWebAsset: AssetGroups property, FilterByGroup, SortByRelatedAssetInPlace

JSON manifest packaging:
- GeneratePackageAssetsManifestFile: write {PackageId}.PackageAssets.json
- GeneratePackageAssetsTargetsFile: write lightweight .targets with manifest ref
- ReadPackageAssetsManifest: read JSON, apply group filtering, materialize framework assets

Target changes across .targets, .Pack.targets, .Publish.targets, .References.targets
Add AssetGroupsSample test project with:
- IdentityUILib: library with V4/V5 versioned wwwroot assets and
  StaticWebAssets.Groups.targets defining the group structure
- IdentityUIConsumer: basic consumer referencing IdentityUILib
- IdentityUIConsumerV4/V5: consumers that select specific asset groups

Update AppWithPackageAndP2PReference.csproj to support pack testing
with the new JSON manifest format.
@javiercn javiercn force-pushed the javiercn/asset-groups branch from 5b4128e to dcd7410 Compare March 17, 2026 14:14
@javiercn javiercn marked this pull request as ready for review March 17, 2026 14:14
@javiercn javiercn requested review from a team as code owners March 17, 2026 14:14
Copilot AI review requested due to automatic review settings March 17, 2026 14:14
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR introduces Static Web Asset Groups support to the Static Web Assets pipeline, enabling libraries to ship multiple asset variants (e.g., Bootstrap V4/V5) and allowing consuming projects to declaratively select which variant is used. It also adds a new JSON-manifest + lightweight .targets packaging path (for newer TFMs) and integrates group-based filtering into build/publish endpoint generation.

Changes:

  • Add group tagging/filtering support across project, package, and P2P static web assets (including deferred group support and cascading exclusion of related assets/endpoints).
  • Introduce JSON package manifest generation + consumption and generate lightweight .targets files to reference the manifest.
  • Add new unit + integration tests and update existing baselines to reflect new manifest/path behavior.

Reviewed changes

Copilot reviewed 80 out of 80 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/StaticWebAssetsSdk/Tasks/DefineStaticWebAssets.cs Adds group definition parsing and applies group tagging + path/content-root rewriting during asset definition.
src/StaticWebAssetsSdk/Tasks/FilterStaticWebAssetGroups.cs New task to filter assets/endpoints by group declarations, supporting deferred groups.
src/StaticWebAssetsSdk/Tasks/ReadPackageAssetsManifest.cs New task to read JSON package manifests, apply group filtering, and materialize framework assets.
src/StaticWebAssetsSdk/Tasks/GeneratePackageAssetsManifestFile.cs New task to emit JSON package manifest (assets + endpoints) with remapped asset references.
src/StaticWebAssetsSdk/Tasks/GeneratePackageAssetsTargetsFile.cs New task to generate lightweight .targets that points to the JSON manifest.
src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets*.targets Wires group filtering into build/publish and adds JSON packaging + restore-time manifest reading.
src/StaticWebAssetsSdk/Tasks/UpdateExternallyDefinedStaticWebAssets.cs Applies group filtering at the P2P boundary with SkipDeferred=true and filters endpoints accordingly.
src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetPathPattern.cs / StaticWebAssetPathSegment.cs Adds ~ file-only token modifier to hide segments from endpoint routes while keeping file resolution.
test/Microsoft.NET.Sdk.StaticWebAssets.Tests/* Adds unit + integration coverage for group filtering, deferred groups, and JSON packaging; updates baselines.
test/TestAssets/TestProjects/AssetGroupsSample/* Adds sample producer/consumer projects and convention targets file for groups.
test/TestAssets/TestProjects/...AppWithPackageAndP2PReference.csproj Updates test target ordering to ensure package-manifest reading happens before checks.
src/BlazorWasmSdk/Tasks/Microsoft.NET.Sdk.BlazorWebAssembly.Tasks.csproj Updates linked StaticWebAssets task data files list (includes group data + OSPath).

You can also share your feedback on Copilot code review. Take the survey.

Comment on lines +6 to +37
using System.Xml;
using Microsoft.AspNetCore.StaticWebAssets.Tasks.Utils;
using Microsoft.Build.Framework;

namespace Microsoft.AspNetCore.StaticWebAssets.Tasks;

// Generates a lightweight .targets file that adds a single StaticWebAssetPackageManifest
// item pointing to the JSON manifest file. This replaces the heavyweight XML .props files
// that contained all asset/endpoint data as MSBuild items.
public class GeneratePackageAssetsTargetsFile : Task
{
[Required]
public string PackageId { get; set; }

[Required]
public string TargetFilePath { get; set; }

public string PackagePathPrefix { get; set; } = "staticwebassets";

[Required]
public string ManifestFileName { get; set; }

public override bool Execute()
{
var normalizedPrefix = PackagePathPrefix.Replace("/", "\\").TrimStart('\\');

var itemGroup = new XElement("ItemGroup");
var manifestItem = new XElement("StaticWebAssetPackageManifest",
new XAttribute("Include", $@"$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory){ManifestFileName}'))"),
new XElement("SourceId", PackageId),
new XElement("ContentRoot", $@"$(MSBuildThisFileDirectory)..\{normalizedPrefix}\"),
new XElement("PackageRoot", @"$([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..'))"));
Comment on lines +17 to +27
[Required]
public ITaskItem[] PackageManifests { get; set; }

public ITaskItem[] StaticWebAssetGroups { get; set; }

public string IntermediateOutputPath { get; set; }

public string ProjectPackageId { get; set; }

public string ProjectBasePath { get; set; }

Comment on lines +171 to +172
}
manifestEndpoints.Add(endpoint);
Comment on lines +659 to +663
public string Name { get; }
public string Value { get; }
public string SourceId { get; }
public int Order { get; }
public StaticWebAssetGlobMatcher IncludeMatcher { get; }
Copy link
Member Author

Choose a reason for hiding this comment

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

These are required, we should throw if not provided

Comment on lines +750 to +754
if (def.IncludeMatcher == null)
{
Log.LogMessage(MessageImportance.Low,
"Skipping group definition '{0}' because it has no IncludePattern.", def.Name);
continue;
Copy link
Member Author

Choose a reason for hiding this comment

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

Having no include pattern is a bug. We must throw when constructing the definition. This is not needed then.

return definitions;
}

private (List<string> GroupEntries, string RelativePath, (string Suffix, string GroupName)? ContentRootSuffix) MatchAssetToDefinitions(
Copy link
Member Author

Choose a reason for hiding this comment

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

Make the result into a private nested readonly struct, instead of this tuple soup


foreach (var def in definitions)
{
if (def.IncludeMatcher == null)
Copy link
Member Author

Choose a reason for hiding this comment

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

The first thing we need to compare here is the asset sourceid vs the definition sourceid, if they don't match the definition doesn't apply

return definitions;
}

private (List<string> GroupEntries, string RelativePath, (string Suffix, string GroupName)? ContentRootSuffix) MatchAssetToDefinitions(
Copy link
Member Author

Choose a reason for hiding this comment

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

When multiple definitions apply for the same group (with different names, like say, version and theme) the expected logic is (since they are sorted) that any transformations to the contentroot and relative path happen in that order and compose. For example #[{version}/]~#[{theme}/]~ for two groups with order 0 and 1 that have both defined relativepathpatterns.


for (var i = 0; i < Assets.Length; i++)
{
var asset = StaticWebAsset.FromTaskItem(Assets[i]);
Copy link
Member Author

Choose a reason for hiding this comment

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

You shouldn't be doing this here. Assets are an output and you shouldn't operate on it by transforming it back to a strongly typed representation.


private void ApplyGroupDefinitions()
{
var definitions = ParseGroupDefinitions();
Copy link
Member Author

Choose a reason for hiding this comment

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

Might make more sense to parse this into a dictionary<string,List> where the key is the SourceId

Comment on lines 305 to 310
var item = asset.ToTaskItem();
if (SourceType == StaticWebAsset.SourceTypes.Discovered)
{
item.SetMetadata(nameof(StaticWebAsset.AssetKind), !asset.ShouldCopyToPublishDirectory() ? StaticWebAsset.AssetKinds.Build : StaticWebAsset.AssetKinds.All);
UpdateAssetKindIfNecessary(assetsByRelativePath, asset.RelativePath, item);
}
Copy link
Member Author

Choose a reason for hiding this comment

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

ApplyGroups needs to happen before this bit.

Unit tests covering:
- AssetGroupFilteringTest: end-to-end group filtering scenarios
- FilterStaticWebAssetGroupsTest: two-pass filtering logic (SkipDeferred)
- GeneratePackageAssetsManifestFileTest: JSON manifest serialization
- GeneratePackageAssetsTargetsFileTest: lightweight .targets generation
- ReadPackageAssetsManifestTest: manifest deserialization and group filtering
- ResolveCompressedAssetsTest: compression with asset groups
- StaticWebAssetTest: AssetGroups property and related asset sorting
Integration tests:
- AssetGroupsIntegrationTest: build/publish with asset group selection,
  verifies correct assets appear in output based on selected group
- DeferredAssetGroupsIntegrationTest: deferred group resolution during
  restore and P2P reference scenarios
- FrameworkAssetsIntegrationTest: framework asset materialization from
  JSON manifests with group filtering
- StaticWebAssetsPackIntegrationTest: pack produces JSON manifest with
  correct asset group metadata and .targets entry point

Regenerate 32 baseline files to incorporate AssetGroups metadata and
new JSON manifest packaging output.
@javiercn javiercn force-pushed the javiercn/asset-groups branch from dcd7410 to 4fa6111 Compare March 17, 2026 15:43
javiercn added 12 commits March 17, 2026 17:05
Move ParseGroupDefinitions() call from ApplyGroupDefinitions() to the
beginning of Execute(). The parsed definitions list is now a local
variable passed into ApplyGroupDefinitions(definitions) rather than
re-parsed on every call. Validation errors (duplicate Order+SourceId)
are caught before any asset processing begins.
Replace the post-loop ApplyGroupDefinitions() bulk operation with a
per-asset ApplyGroupToAsset() call inside the main processing loop.
Group definitions are now applied to each StaticWebAsset instance
before it is serialized via ToTaskItem(), eliminating the need to
re-parse ITaskItem back to StaticWebAsset after the fact.

This means group-modified RelativePath and ContentRoot values are
visible to UpdateAssetKindIfNecessary for dedup/conflict detection.
Group definitions can rewrite RelativePath so that assets from
different groups (e.g. V4/css/site.css and V5/css/site.css) collapse
to the same post-group path (css/site.css). UpdateAssetKindIfNecessary
must use the original pre-group path for its conflict detection, since
group variants are not true conflicts — they are resolved at runtime
by FilterStaticWebAssetGroups.
Copy link
Member

@maraf maraf left a comment

Choose a reason for hiding this comment

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

Looks good to me 👍

Comment on lines +882 to +893
if (!string.IsNullOrEmpty(def.ContentRootSuffix))
{
if (contentRootSuffix != null && !string.Equals(contentRootSuffix, def.ContentRootSuffix, StringComparison.Ordinal))
{
Log.LogError(
"Asset '{0}' matched group definitions '{1}' and '{2}' with conflicting ContentRootSuffix values '{3}' and '{4}'.",
asset.Identity, contentRootGroupName, def.Name, contentRootSuffix, def.ContentRootSuffix);
return default;
}
contentRootSuffix = def.ContentRootSuffix;
contentRootGroupName = def.Name;
}
Copy link
Member Author

Choose a reason for hiding this comment

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

This logic is wrong, isn't it? Content root suffixes need to compose, same as relative path prefixes. Say I have version and theme /v4|v5/light|dark/path/to/asset. The group is meant to support stripping the /v4|v5/light|dark/ and moving it on to the content root suffix, so relative path becomes path/to/asset and content root becomes OriginalContentRoot/v4/light

Introduce TokenResolveMode (None/Pack/Serve) to control how token
expressions are resolved during path computation. The ~ modifier
now means 'pack-only' — the segment appears in physical NuGet
package paths (Pack mode) but is stripped from HTTP routes and
dev manifests (Serve mode).

- Rename IsFileOnly to IsPackOnly on StaticWebAssetPathSegment
- Rename PatternFileOnly to PatternPackOnly
- Replace bool applyPreferences with TokenResolveMode resolveMode
  on StaticWebAssetPathPattern.ReplaceTokens()
- Add resolveMode parameter to ComputeTargetPath and ReplaceTokens
  on StaticWebAsset, defaulting to Serve
- Update all pack call sites to use TokenResolveMode.Pack:
  GenerateStaticWebAssetsPropsFile, GenerateStaticWebAssetEndpointsPropsFile,
  GeneratePackageAssetsManifestFile
When multiple group definitions with different names (e.g., version and
theme) match the same asset, their ContentRootSuffix values now compose
sequentially instead of erroring. Definitions are sorted by Order so
composition is deterministic. For example, version=v4 (suffix 'v4') +
theme=light (suffix 'light') produces 'OriginalRoot/v4/light'.

Updated test to verify composition behavior.
ComputeStaticWebAssetsTargetPaths was using TokenResolveMode.Serve
(the default) when computing pack target paths, which strips
pack-only (~) segments from relative paths. This caused group
token expressions like #[{BootstrapVersion}]~/ to be stripped,
resulting in nupkg entries like staticwebassets//css/site.css
instead of staticwebassets/V4/css/site.css.

Use TokenResolveMode.Pack when AdjustPathsForPack is true so
pack-only segments are resolved rather than stripped.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants