Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 @@ -371,7 +371,7 @@ Integration with static web assets:

</Target>

<Target Name="_ComputeScopedCssStaticWebAssets" DependsOnTargets="ResolveScopedCssOutputs;ResolveStaticWebAssetsConfiguration">
<Target Name="_ComputeScopedCssStaticWebAssets" DependsOnTargets="_ProcessScopedCssFiles;ResolveStaticWebAssetsConfiguration">
<ItemGroup>
<_ScopedCssCandidateFile Include="%(_ScopedCss.OutputFile)" Condition="@(_ScopedCss) != ''">
<RelativePath>%(_ScopedCss.RelativePath)</RelativePath>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -188,8 +188,7 @@ Copyright (c) .NET Foundation. All rights reserved.
</ItemGroup>

<ItemGroup>
<_BuildAssetsForManifestCandidate Include="@(StaticWebAsset)" Condition="'%(AssetRole)' != 'Alternative' and Exists('%(Identity)')" />
<_BuildAssetsForManifestCandidate Include="@(StaticWebAsset->'%(OriginalItemSpec)')" Condition="'%(AssetRole)' != 'Alternative' and !Exists('%(Identity)')" />
<_BuildAssetsForManifestCandidate Include="@(StaticWebAsset)" Condition="'%(AssetRole)' != 'Alternative'" />
</ItemGroup>

<MakeDir Directories="$(IntermediateOutputPath)service-worker" />
Expand Down
25 changes: 1 addition & 24 deletions src/StaticWebAssetsSdk/Tasks/CollectStaticWebAssetsToCopy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,31 +43,8 @@ public override bool Execute()
{
// We have an asset we want to copy to the output folder.
fileOutputPath = Path.Combine(normalizedOutputPath, asset.ComputeTargetPath("", Path.DirectorySeparatorChar, StaticWebAssetTokenResolver.Instance));
string source = null;
if (asset.IsComputed())
{
if (asset.Identity.StartsWith(normalizedOutputPath, StringComparison.Ordinal))
{
Log.LogMessage(MessageImportance.Low, "Source for asset '{0}' is '{1}' since the identity points to the output path.", asset.Identity, asset.OriginalItemSpec);
source = asset.OriginalItemSpec;
}
else if (File.Exists(asset.Identity))
{
Log.LogMessage(MessageImportance.Low, "Source for asset '{0}' is '{0}' since the asset exists.", asset.Identity);
source = asset.Identity;
}
else
{
Log.LogMessage(MessageImportance.Low, "Source for asset '{0}' is '{1}' since the asset does not exist.", asset.Identity, asset.OriginalItemSpec);
source = asset.OriginalItemSpec;
}
}
else
{
source = asset.Identity;
}

copyToOutputFolder.Add(new TaskItem(source, new Dictionary<string, string>
copyToOutputFolder.Add(new TaskItem(asset.Identity, new Dictionary<string, string>
{
["OriginalItemSpec"] = asset.Identity,
["TargetPath"] = fileOutputPath,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,6 @@ public override bool Execute()
out var compressedAsset))
{
var result = compressedAsset.ToTaskItem();
result.SetMetadata("RelatedAssetOriginalItemSpec", asset.OriginalItemSpec);

assetsToCompress[assetCounter++] = result;
existingFormats.Add(format);
Expand Down
23 changes: 6 additions & 17 deletions src/StaticWebAssetsSdk/Tasks/Data/StaticWebAsset.cs
Original file line number Diff line number Diff line change
Expand Up @@ -830,7 +830,7 @@ public void ApplyDefaults()
AssetRole = string.IsNullOrEmpty(AssetRole) ? AssetRoles.Primary : AssetRole;
if (string.IsNullOrEmpty(Fingerprint) || string.IsNullOrEmpty(Integrity) || FileLength == -1 || LastWriteTime == DateTimeOffset.MinValue)
{
var file = ResolveFile(Identity, OriginalItemSpec);
var file = ResolveFile();
(Fingerprint, Integrity) = string.IsNullOrEmpty(Fingerprint) || string.IsNullOrEmpty(Integrity) ?
ComputeFingerprintAndIntegrityIfNeeded(file) : (Fingerprint, Integrity);
FileLength = FileLength == -1 ? file.Length : FileLength;
Expand Down Expand Up @@ -859,9 +859,9 @@ internal static (string fingerprint, string integrity) ComputeFingerprintAndInte
return (FileHasher.ToBase36(hash), Convert.ToBase64String(hash));
}

internal static string ComputeIntegrity(string identity, string originalItemSpec)
internal static string ComputeIntegrity(string identity)
{
var fileInfo = ResolveFile(identity, originalItemSpec);
var fileInfo = ResolveFile(identity);
return ComputeIntegrity(fileInfo);
}

Expand Down Expand Up @@ -1540,22 +1540,11 @@ internal string EmbedTokens(string relativePath)
return pattern.RawPattern.ToString();
}

internal FileInfo ResolveFile() => ResolveFile(Identity, OriginalItemSpec);
internal FileInfo ResolveFile() => new FileInfo(Identity);

internal static FileInfo ResolveFile(string identity, string originalItemSpec)
internal static FileInfo ResolveFile(string identity)
{
var fileInfo = new FileInfo(identity);
if (fileInfo.Exists)
{
return fileInfo;
}
fileInfo = new FileInfo(originalItemSpec);
if (fileInfo.Exists)
{
return fileInfo;
}

throw new InvalidOperationException($"No file exists for the asset at either location '{identity}' or '{originalItemSpec}'.");
return new FileInfo(identity);
}
Comment on lines +1543 to 1548
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

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

StaticWebAsset.ResolveFile now returns a FileInfo without validating existence. Callers like ApplyDefaults/ComputeIntegrity will then fail later with FileNotFoundException (or when accessing Length), which is less actionable than the previous targeted InvalidOperationException. Consider reintroducing an explicit Exists check here (and throwing a clear exception that includes the asset Identity) to keep failures deterministic and diagnosable.

Copilot uses AI. Check for mistakes.

internal static Dictionary<string, StaticWebAsset> ToAssetDictionary(ITaskItem[] candidateAssets, bool validate = false)
Expand Down
32 changes: 1 addition & 31 deletions src/StaticWebAssetsSdk/Tasks/DefineStaticWebAssets.Cache.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,6 @@ private DefineStaticWebAssetsCache GetOrCreateAssetsCache()
internal class DefineStaticWebAssetsCache
{
private readonly List<ITaskItem> _assets = [];
private readonly List<ITaskItem> _copyCandidates = [];
private string? _manifestPath;
private IDictionary<string, ITaskItem>? _inputByHash;
private ITaskItem[]? _noCacheCandidates;
Expand All @@ -90,7 +89,6 @@ internal DefineStaticWebAssetsCache(TaskLoggingHelper log, string? manifestPath)

// Outputs for the cache
public Dictionary<string, StaticWebAsset> CachedAssets { get; set; } = [];
public Dictionary<string, CopyCandidate> CachedCopyCandidates { get; set; } = [];

internal static DefineStaticWebAssetsCache ReadOrCreateCache(TaskLoggingHelper log, string manifestPath)
{
Expand Down Expand Up @@ -131,16 +129,6 @@ internal void AppendAsset(string hash, StaticWebAsset asset, ITaskItem item)
}
}

internal void AppendCopyCandidate(string hash, string identity, string targetPath)
{
var copyCandidate = new CopyCandidate(identity, targetPath);
_copyCandidates.Add(copyCandidate.ToTaskItem());
if (!string.IsNullOrEmpty(hash))
{
CachedCopyCandidates[hash] = copyCandidate;
}
}

internal void Update(
byte[] propertiesHash,
byte[] fingerprintPatternsHash,
Expand All @@ -166,7 +154,6 @@ private void TotalUpdate(byte[] propertiesHash, byte[] fingerprintPatternsHash,
FingerprintPatternsHash = fingerprintPatternsHash;
PropertyOverridesHash = propertyOverridesHash;
CachedAssets.Clear();
CachedCopyCandidates.Clear();
InputHashes = [.. inputsByHash.Keys];
_inputByHash = inputsByHash;
}
Expand All @@ -189,10 +176,6 @@ private void PartialUpdate(Dictionary<string, ITaskItem> inputHashes)
{
_assets.Add(cachedAsset.Value.ToTaskItem());
}
foreach (var cachedCopyCandidate in CachedCopyCandidates)
{
_copyCandidates.Add(cachedCopyCandidate.Value.ToTaskItem());
}

_cacheUpToDate = true;
_log?.LogMessage(MessageImportance.Low, "Cache is fully up to date.");
Expand All @@ -212,10 +195,6 @@ private void PartialUpdate(Dictionary<string, ITaskItem> inputHashes)
{
_log?.LogMessage(MessageImportance.Low, "Asset {0} is up to date", candidate.ItemSpec);
_assets.Add(asset.ToTaskItem());
if (CachedCopyCandidates.TryGetValue(hash, out var copyCandidate))
{
_copyCandidates.Add(copyCandidate.ToTaskItem());
}
}
}

Expand All @@ -225,15 +204,14 @@ private void PartialUpdate(Dictionary<string, ITaskItem> inputHashes)
foreach (var hash in assetsToRemove)
{
CachedAssets.Remove(hash);
CachedCopyCandidates.Remove(hash);
}

_inputByHash = remainingCandidates;
}

internal void SetPathAndLogger(string? manifestPath, TaskLoggingHelper log) => (_manifestPath, _log) = (manifestPath, log);

public (IList<ITaskItem> CopyCandidates, IList<ITaskItem> Assets) GetComputedOutputs() => (_copyCandidates, _assets);
public IList<ITaskItem> GetComputedOutputs() => _assets;

internal void NoCache(ITaskItem[] candidateAssets)
{
Expand Down Expand Up @@ -264,14 +242,6 @@ IEnumerable<KeyValuePair<string, ITaskItem>> EnumerateNoCache()
internal bool IsUpToDate() => _cacheUpToDate;
}

internal class CopyCandidate(string identity, string targetPath)
{
public string Identity { get; set; } = identity;
public string TargetPath { get; set; } = targetPath;

internal ITaskItem ToTaskItem() => new TaskItem(Identity, new Dictionary<string, string> { ["TargetPath"] = TargetPath });
}

[JsonSerializable(typeof(DefineStaticWebAssetsCache))]
[JsonSourceGenerationOptions(
GenerationMode = JsonSourceGenerationMode.Metadata | JsonSourceGenerationMode.Serialization,
Expand Down
91 changes: 15 additions & 76 deletions src/StaticWebAssetsSdk/Tasks/DefineStaticWebAssets.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,6 @@ public partial class DefineStaticWebAssets : Task
[Output]
public ITaskItem[] Assets { get; set; }

[Output]
public ITaskItem[] CopyCandidates { get; set; }

public Func<string, string, (FileInfo file, long fileLength, DateTimeOffset lastWriteTimeUtc)> TestResolveFileDetails { get; set; }

private HashSet<string> _overrides;
Expand All @@ -99,9 +96,7 @@ public override bool Execute()

if (assetsCache.IsUpToDate())
{
var outputs = assetsCache.GetComputedOutputs();
Assets = [.. outputs.Assets];
CopyCandidates = [.. outputs.CopyCandidates];
Assets = [.. assetsCache.GetComputedOutputs()];
}
else
{
Expand Down Expand Up @@ -266,23 +261,7 @@ public override bool Execute()
{
// We ignore the content root for publish only assets since it doesn't matter.
var contentRootPrefix = StaticWebAsset.AssetKinds.IsPublish(assetKind) ? null : contentRoot;
(identity, var computed) = ComputeCandidateIdentity(candidate, contentRootPrefix, relativePathCandidate, matcher, matchContext);

if (computed)
{
// If we synthesized identity and there is a fingerprint placeholder pattern in the file name
// expand it to the concrete fingerprinted file name while keeping RelativePath pattern form.
if (FingerprintCandidates && !string.IsNullOrEmpty(fingerprint))
{
var fileNamePattern = Path.GetFileName(identity);
if (fileNamePattern.Contains("#["))
{
var expanded = StaticWebAssetPathPattern.ExpandIdentityFileNameForFingerprint(fileNamePattern, fingerprint);
identity = Path.Combine(Path.GetDirectoryName(identity) ?? string.Empty, expanded);
}
}
assetsCache.AppendCopyCandidate(hash, candidate.ItemSpec, identity);
}
identity = ComputeCandidateIdentity(candidate, contentRootPrefix);
}

var asset = StaticWebAsset.FromProperties(
Expand Down Expand Up @@ -340,13 +319,9 @@ public override bool Execute()
assetsCache.AppendAsset(hash, asset, item);
}

var outputs = assetsCache.GetComputedOutputs();
var results = outputs.Assets;

assetsCache.WriteCacheManifest();

Assets = [.. outputs.Assets];
CopyCandidates = [.. outputs.CopyCandidates];
Assets = [.. assetsCache.GetComputedOutputs()];
}
catch (Exception ex)
{
Expand All @@ -365,75 +340,39 @@ public override bool Execute()
{
return TestResolveFileDetails(identity, originalItemSpec);
}
var file = StaticWebAsset.ResolveFile(identity, originalItemSpec);
var file = new FileInfo(identity);
if (!file.Exists)
{
throw new InvalidOperationException($"No file exists for the asset at location '{identity}'.");
}
var fileLength = file.Length;
var lastWriteTimeUtc = file.LastWriteTimeUtc;
return (file, fileLength, lastWriteTimeUtc);
}

private (string identity, bool computed) ComputeCandidateIdentity(
private string ComputeCandidateIdentity(
ITaskItem candidate,
string contentRoot,
string relativePath,
StaticWebAssetGlobMatcher matcher,
StaticWebAssetGlobMatcher.MatchContext matchContext)
string contentRoot)
{
var candidateFullPath = Path.GetFullPath(candidate.GetMetadata("FullPath"));
if (contentRoot == null)
{
Log.LogMessage(MessageImportance.Low, "Identity for candidate '{0}' is '{1}' because content root is not defined.", candidate.ItemSpec, candidateFullPath);
return (candidateFullPath, false);
return candidateFullPath;
}

var normalizedContentRoot = StaticWebAsset.NormalizeContentRootPath(contentRoot);
if (candidateFullPath.StartsWith(normalizedContentRoot))
{
Log.LogMessage(MessageImportance.Low, "Identity for candidate '{0}' is '{1}' because it starts with content root '{2}'.", candidate.ItemSpec, candidateFullPath, normalizedContentRoot);
return (candidateFullPath, false);
}
else
{
// We want to support assets that are part of the source codebase but that might get transformed during the build or
// publish processes, so we want to allow defining these assets by setting up a different content root path from their
// original location in the project. For example the asset can be wwwroot\my-prod-asset.js, the content root can be
// obj\transform and the final asset identity can be <<FullPathTo>>\obj\transform\my-prod-asset.js
GlobMatch matchResult = default;
if (matcher != null)
{
matchContext.SetPathAndReinitialize(StaticWebAssetPathPattern.PathWithoutTokens(candidate.ItemSpec));
matchResult = matcher.Match(matchContext);
}
if (matcher == null)
{
// If no relative path pattern was specified, we are going to suggest that the identity is `%(ContentRoot)\RelativePath\OriginalFileName`
// We don't want to use the relative path file name since multiple assets might map to that and conflicts might arise.
// Alternatively, we could be explicit here and support ContentRootSubPath to indicate where it needs to go.
var identitySubPath = Path.GetDirectoryName(relativePath);
var itemSpecFileName = Path.GetFileName(candidateFullPath);
var relativeFileName = Path.GetFileName(relativePath);
// If the relative path filename has been modified (e.g. fingerprint pattern appended) use it when synthesizing identity.
if (!string.IsNullOrEmpty(relativeFileName) && !string.Equals(relativeFileName, itemSpecFileName, StringComparison.OrdinalIgnoreCase))
{
itemSpecFileName = relativeFileName;
}
var finalIdentity = Path.Combine(normalizedContentRoot, identitySubPath ?? string.Empty, itemSpecFileName);
Log.LogMessage(MessageImportance.Low, "Identity for candidate '{0}' is '{1}' because it did not start with the content root '{2}'", candidate.ItemSpec, finalIdentity, normalizedContentRoot);
return (finalIdentity, true);
}
else if (!matchResult.IsMatch)
{
Log.LogMessage(MessageImportance.Low, "Identity for candidate '{0}' is '{1}' because it didn't match the relative path pattern", candidate.ItemSpec, candidateFullPath);
return (candidateFullPath, false);
}
else
{
var stem = matchResult.Stem;
var assetIdentity = Path.GetFullPath(Path.Combine(normalizedContentRoot, stem));
Log.LogMessage(MessageImportance.Low, "Computed identity '{0}' for candidate '{1}'", assetIdentity, candidate.ItemSpec);

return (assetIdentity, true);
}
// The asset is not under the content root. Use the candidate's real file path as the identity.
Log.LogMessage(MessageImportance.Low, "Identity for candidate '{0}' is '{1}' because it did not start with the content root '{2}'", candidate.ItemSpec, candidateFullPath, normalizedContentRoot);
}

return candidateFullPath;
}

private string ComputePropertyValue(ITaskItem element, string metadataName, string propertyValue, bool isRequired = true)
Expand Down
17 changes: 2 additions & 15 deletions src/StaticWebAssetsSdk/Tasks/Utils/AssetToCompress.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@ internal static class AssetToCompress
{
public static bool TryFindInputFilePath(ITaskItem assetToCompress, TaskLoggingHelper log, out string fullPath)
{
// Check RelatedAsset first (the asset's Identity path) as it's more reliable.
// RelatedAssetOriginalItemSpec may point to a project file (e.g., .esproj) rather than the actual asset.
var relatedAsset = assetToCompress.GetMetadata("RelatedAsset");
if (File.Exists(relatedAsset))
{
Expand All @@ -24,20 +22,9 @@ public static bool TryFindInputFilePath(ITaskItem assetToCompress, TaskLoggingHe
return true;
}

var relatedAssetOriginalItemSpec = assetToCompress.GetMetadata("RelatedAssetOriginalItemSpec");
if (File.Exists(relatedAssetOriginalItemSpec))
{
log.LogMessage(MessageImportance.Low, "Asset '{0}' found at original item spec '{1}'.",
assetToCompress.ItemSpec,
relatedAssetOriginalItemSpec);
fullPath = relatedAssetOriginalItemSpec;
return true;
}

log.LogError("The asset '{0}' can not be found at any of the searched locations '{1}' and '{2}'.",
log.LogError("The asset '{0}' can not be found at the searched location '{1}'.",
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

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

The error text uses "can not" which should be "cannot".

Suggested change
log.LogError("The asset '{0}' can not be found at the searched location '{1}'.",
log.LogError("The asset '{0}' cannot be found at the searched location '{1}'.",

Copilot uses AI. Check for mistakes.
assetToCompress.ItemSpec,
relatedAsset,
relatedAssetOriginalItemSpec);
relatedAsset);
fullPath = null;
return false;
Comment on lines 15 to 29
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

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

TryFindInputFilePath logs a "not found" error even when RelatedAsset metadata is empty, which produces an unclear message (searched location ''). Consider explicitly checking string.IsNullOrWhiteSpace(relatedAsset) first and logging a targeted error that the required RelatedAsset metadata is missing/empty.

Copilot uses AI. Check for mistakes.
}
Expand Down
Loading
Loading