Add Asset Groups support for static web assets#53187
Conversation
e0624be to
f2a0c0b
Compare
69adb59 to
3f32e43
Compare
src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetPackageManifest.cs
Outdated
Show resolved
Hide resolved
src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetTokenResolver.cs
Outdated
Show resolved
Hide resolved
src/StaticWebAssetsSdk/Tasks/Utils/StaticWebAssetGroupFilter.cs
Outdated
Show resolved
Hide resolved
src/StaticWebAssetsSdk/Tasks/Utils/StaticWebAssetGroupFilter.cs
Outdated
Show resolved
Hide resolved
src/StaticWebAssetsSdk/Tasks/Utils/StaticWebAssetGroupFilter.cs
Outdated
Show resolved
Hide resolved
src/StaticWebAssetsSdk/Tasks/GeneratePackageAssetsManifestFile.cs
Outdated
Show resolved
Hide resolved
src/StaticWebAssetsSdk/Tasks/GenerateStaticWebAssetsPropsFile.cs
Outdated
Show resolved
Hide resolved
f1b2f9d to
a827e4d
Compare
src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.Pack.targets
Outdated
Show resolved
Hide resolved
src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.Pack.targets
Outdated
Show resolved
Hide resolved
src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.targets
Show resolved
Hide resolved
test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/ReadPackageAssetsManifestTest.cs
Show resolved
Hide resolved
src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.Pack.targets
Show resolved
Hide resolved
af7dca6 to
0d2f054
Compare
src/StaticWebAssetsSdk/Tasks/GeneratePackageAssetsManifestFile.cs
Outdated
Show resolved
Hide resolved
src/StaticWebAssetsSdk/Tasks/UpdateExternallyDefinedStaticWebAssets.cs
Outdated
Show resolved
Hide resolved
src/StaticWebAssetsSdk/Tasks/UpdateExternallyDefinedStaticWebAssets.cs
Outdated
Show resolved
Hide resolved
… 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.
5b4128e to
dcd7410
Compare
There was a problem hiding this comment.
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
.targetsfiles 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.
| 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)..'))")); |
| [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; } | ||
|
|
| } | ||
| manifestEndpoints.Add(endpoint); |
| public string Name { get; } | ||
| public string Value { get; } | ||
| public string SourceId { get; } | ||
| public int Order { get; } | ||
| public StaticWebAssetGlobMatcher IncludeMatcher { get; } |
There was a problem hiding this comment.
These are required, we should throw if not provided
| if (def.IncludeMatcher == null) | ||
| { | ||
| Log.LogMessage(MessageImportance.Low, | ||
| "Skipping group definition '{0}' because it has no IncludePattern.", def.Name); | ||
| continue; |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
Make the result into a private nested readonly struct, instead of this tuple soup
|
|
||
| foreach (var def in definitions) | ||
| { | ||
| if (def.IncludeMatcher == null) |
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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]); |
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
Might make more sense to parse this into a dictionary<string,List> where the key is the SourceId
| 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); | ||
| } |
There was a problem hiding this comment.
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.
dcd7410 to
4fa6111
Compare
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.
…tRootSuffix conflicts
| 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; | ||
| } |
There was a problem hiding this comment.
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.
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/aspnetcorethat currently bypass the standard Static Web Assets pipeline with hand-written MSBuild logic. Identity UI ships Bootstrap V4 and V5 CSS/JS under separateassets/V4/andassets/V5/directories, using a customGetIdentityUIAssetstarget 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) andStaticWebAssetGroup(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
GetIdentityUIAssetstarget withStaticWebAssetsGetBuildAssetsTargetsto select V4 or V5 assets based onIdentityDefaultUIFramework, and a separate_GenerateIdentityUIPackItemstarget to manually generate per-variant XML props files and compute target paths. Assets.Internal uses_AddBlazorFrameworkStaticWebAssetsto 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 declareStaticWebAssetGroupDefinitionitems to tag assets with named groups, and consumers should declareStaticWebAssetGroupitems (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.cssregardless 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 theRelativePathvia aRelativePathPatternand replacing it with a file-only group token (#[{GroupName}]~/) viaRelativePathPrefix, 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
OutputTypeandUsingMicrosoftNETSdkWebto decide which Blazor JS files to include. In the new model, the group value may depend on whether the consumer referencesComponents.EndpointsorComponents.Server— information only available after reference resolution. The feature must support deferred groups: groups declared withDeferred="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.
AssetGroupsmetadata on assets must survive packaging into a NuGet package (in the JSON manifest) and be available for filtering when the consumer restores the package. TheStaticWebAssets.Groups.targetsconvention file must also be packed and automatically imported by the consumer.Eliminate custom MSBuild in aspnetcore for asset variant selection. The
GetIdentityUIAssetstarget (with itsStaticWebAssetsGetBuildAssetsTargetshook),_GenerateIdentityUIPackItemstarget (with per-variantGenerateStaticWebAssetsPropsFile/GenerateStaticWebAssetEndpointsPropsFile/ComputeStaticWebAssetsTargetPathscalls and per-variantStaticWebAssetPackageFileitems), the conditional.propsimport chain (Microsoft.AspNetCore.StaticWebAssets.V4.targets/V5.targets), and the_AddBlazorFrameworkStaticWebAssetstarget 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
GetCurrentProjectBuildStaticWebAssetItemspattern for P2P asset resolution is unchanged. Group filtering for P2P references is handled byUpdateExternallyDefinedStaticWebAssetswithSkipDeferred=trueat the reference boundary andFilterStaticWebAssetGroupsat 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
.propspackaging path. The oldGenerateStaticWebAssetsPropsFile/GenerateStaticWebAssetEndpointsPropsFiletasks and their.propsimport 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,
StaticWebAssetGroupDefinitionitems tag assets with named group values duringDefineStaticWebAssets. 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), andIncludePattern(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), andContentRootSuffix(appended toContentRootso variants resolve to separate disk folders).Definitions are grouped by
SourceIdand only evaluated against assets whoseSourceIdmatches, 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., aBootstrapVersiongroup at Order 0 and aThemegroup at Order 1), their transformations compose inOrdersequence:RelativePathPrefixvalues concatenate (producing e.g.#[{BootstrapVersion}]~/#[{Theme}]~/),ContentRootSuffixvalues compose via path joining, and each definition's groupName=Valuepair is appended to the asset'sAssetGroupsmetadata. If two matching definitions specify differentContentRootSuffixvalues, a build error is produced — conflicting content root adjustments must be resolved explicitly.On the consumer side,
StaticWebAssetGroupitems declare which value to use for each named group — theFilterStaticWebAssetGroupstask matches these declarations against theAssetGroupsmetadata 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). TheGeneratePackageAssetsManifestFiletask validates that every endpoint'sAssetFilecan 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. TheReadPackageAssetsManifesttask validates thatIntermediateOutputPathis set before processing (required for framework asset materialization), then applies group filtering at read time withSkipDeferred=true, so only the selected variant's assets are materialized as MSBuild items. The convention fileStaticWebAssets.Groups.targets(if present in the project directory) is automatically packed intobuild/StaticWebAssets.Groups.targetsand imported by the generated{PackageId}.targets, giving the consumer a default property and the correspondingStaticWebAssetGroupitem 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 theFilterDeferredStaticWebAssetGroupsDependsOnextension point before the final filtering pass (withSkipDeferred=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:
Library —
StaticWebAssets.Groups.targets(convention file, auto-packed):Consumer — project file (selects V4):
Expected outcome: Only V4 assets appear in the endpoints manifest. Endpoint routes are
Identity/css/site.cssandIdentity/js/site.js— theV4/prefix is stripped byRelativePathPatternand the#[{BootstrapVersion}]~/prefix resolves toV4/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):
StaticWebAssetsGetBuildAssetsTargets=GetIdentityUIAssetsto hook a custom target that callsDefineStaticWebAssetswith a conditionalContentRootbased onIdentityDefaultUIFramework_GenerateIdentityUIPackItemsmanually callsDefineStaticWebAssets,DefineStaticWebAssetEndpoints,GenerateStaticWebAssetsPropsFile,GenerateStaticWebAssetEndpointsPropsFile, andComputeStaticWebAssetsTargetPathsseparately for V4 and V5Microsoft.AspNetCore.StaticWebAssets.V4.targets,V4.endpoints.targets,V5.targets,V5.endpoints.targetsIdentityUIFrameworkVersionAfter (with asset groups):
StaticWebAssetGroupDefinitionitems and shipsStaticWebAssets.Groups.targetsStaticWebAssetGroupitem2. Consumer selects a group from a project reference (aspnetcore inner-repo pattern)
Inside the aspnetcore repo, projects reference
Microsoft.AspNetCore.Assets.InternalviaProjectReference(withReferenceOutputAssemblies=false). The consumer imports the referenced project'sStaticWebAssets.Groups.targetsmanually and sets the group property.Consumer — project file:
Expected outcome: The
StaticWebAssets.Groups.targetsfile from the referenced project defines theStaticWebAssetGroupitem. The consumer's property override (if any) selects the variant. At the P2P boundary,UpdateExternallyDefinedStaticWebAssetsapplies group filtering withSkipDeferred=true, passing through deferred groups and filtering resolved ones.Before: The
_AddBlazorFrameworkStaticWebAssetstarget in the package's build targets manually callsDefineStaticWebAssetsandDefineStaticWebAssetEndpoints, gated onOutputTypeandUsingMicrosoftNETSdkWebconditions. 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.Internalshipsblazor.web.jsandblazor.server.js. The decision of which to include depends on whether the consumer referencesComponents.EndpointsorComponents.Server— information only available after reference resolution in the consumer project.Library —
StaticWebAssets.Groups.targets:Expected outcome:
SkipDeferred=true): All Blazor JS variants pass through — the deferred group is provisionally satisfied.FilterDeferredStaticWebAssetGroupsruns:ResolveBlazorFrameworkGrouptarget executes, inspecting resolved references to determine the value, then replaces the deferred group with a concrete one.SkipDeferred=false):FilterStaticWebAssetGroupsapplies 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:
_AddBlazorFrameworkStaticWebAssetschecksOutputTypeandUsingMicrosoftNETSdkWeb, then callsDefineStaticWebAssetsandDefineStaticWebAssetEndpointsinline. Bothblazor.web.jsandblazor.server.jsare 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:
wwwroot/V5/css/site.cssRelativePathV5/css/site.cssRelativePathPattern(V5/**) strips prefixcss/site.css(stem)RelativePathPrefix(#[{BootstrapVersion}]~/) prepends token#[{BootstrapVersion}]~/css/site.cssContentRootSuffix(V5) adjusts content root…/wwwroot/V5/~= preferred, token resolves)V5/css/site.css→ file found at…/wwwroot/V5/css/site.css~= file-only, skipped)css/site.cssBasePath=IdentityIdentity/css/site.cssThe
~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.cssregardless 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
.gzand.br) and all associated endpoints are also excluded. This cascading happens insideFilterStaticWebAssetGroups→StaticWebAsset.FilterByGroup(), which sorts assets parent-before-child viaSortByRelatedAssetInPlaceand tracks excluded assets in a set. Any asset whoseRelatedAssetpoints to an excluded asset is itself excluded.StaticWebAssetEndpointGroup.ComputeFilteredEndpoints()then partitions endpoints into removed vs. surviving based on whether theirAssetFilewas 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/.brroutes) 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
StaticWebAssetGroupfor that group name, all grouped assets are excluded. Only ungrouped assets pass through. This is becauseMatchesGroups()requires everyName=Valuepair in the asset'sAssetGroupsto have a matching declaration — missing declarations mean the requirement is unsatisfied.Expected outcome: A consumer that adds a
PackageReferenceto a library with grouped assets but doesn't set the group property (and the library'sStaticWebAssets.Groups.targetsprovides 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
ComputeReferenceStaticWebAssetItemsdetects multiple assets targeting the same path, it callsAllAssetsHaveDistinctGroups(). If all conflicting assets have non-empty, distinctAssetGroupsvalues, 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
StaticWebAssets.Groups.targetsconvention file to provide a default group value and map a consumer property to theStaticWebAssetGroupitem. Without this convention file, consumers would need to manually declareStaticWebAssetGroupitems — a worse experience.BootstrapVersionandbootstrapversionare different groups.Name=Valuepair inAssetGroups). The data model supports semicolon-separated multiple pairs, but the current scenarios only need single-group tagging.StaticWebAssetGroupDefinitionandStaticWebAssetGroupitems carry aSourceIdthat scopes their effect. On the producer side, definitions are grouped bySourceIdand only evaluated against assets from that source. On the consumer side, group declarations with aSourceIdonly 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.FilterStaticWebAssetGroupswithSkipDeferred=false). Any still-deferred group at that point causes a build error.~(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.Microsoft.AspNetCore.Identity.UIandMicrosoft.AspNetCore.App.Internal.AssetswithStaticWebAssetGroupDefinition/StaticWebAssetGroupdeclarations and convention files.References
javiercn/asset-groupsondotnet/sdkMicrosoft.AspNetCore.Identity.UI.csproj— current Identity UI with customGetIdentityUIAssetsand_GenerateIdentityUIPackItemstargetsMicrosoft.AspNetCore.App.Internal.Assets.csproj— current Blazor framework assets with custom_AddBlazorFrameworkStaticWebAssetstarget.propsimport chain (V4.targets,V5.targets,V4.endpoints.targets,V5.endpoints.targets)_AddBlazorFrameworkStaticWebAssetstarget that injects Blazor JS files