File: GenerateStaticWebAssetEndpointsManifest.cs
Web Access
Project: ..\..\..\src\StaticWebAssetsSdk\Tasks\Microsoft.NET.Sdk.StaticWebAssets.Tasks.csproj (Microsoft.NET.Sdk.StaticWebAssets.Tasks)
// 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.AspNetCore.StaticWebAssets.Tasks.Utils;
using Microsoft.Build.Framework;
 
namespace Microsoft.AspNetCore.StaticWebAssets.Tasks;
 
public class GenerateStaticWebAssetEndpointsManifest : Task
{
    [Required]
    public ITaskItem[] Assets { get; set; } = [];
 
    [Required]
    public ITaskItem[] Endpoints { get; set; } = [];
 
    [Required]
    public string ManifestType { get; set; }
 
    [Required] public string Source { get; set; }
 
    [Required]
    public string ManifestPath { get; set; }
 
    public string CacheFilePath { get; set; }
 
    public string ExclusionPatterns { get; set; }
 
    public string ExclusionPatternsCacheFilePath { get; set; }
 
    public override bool Execute()
    {
        var (patternString, parsedPatterns) = ParseAndSortPatterns(ExclusionPatterns);
        var existingPatternString = !string.IsNullOrEmpty(ExclusionPatternsCacheFilePath) && File.Exists(ExclusionPatternsCacheFilePath)
            ? File.ReadAllText(ExclusionPatternsCacheFilePath)
            : null;
        existingPatternString = string.IsNullOrEmpty(existingPatternString) ? null : existingPatternString;
        if (!string.IsNullOrEmpty(CacheFilePath) && File.Exists(ManifestPath) && File.GetLastWriteTimeUtc(ManifestPath) > File.GetLastWriteTimeUtc(CacheFilePath))
        {
            // Check if exclusion patterns cache is also up to date
            if (string.Equals(patternString, existingPatternString, StringComparison.Ordinal))
            {
                Log.LogMessage(MessageImportance.Low, "Skipping manifest generation because manifest file '{0}' is up to date.", ManifestPath);
                return true;
            }
            else
            {
                Log.LogMessage(MessageImportance.Low, "Generating manifest file '{0}' because exclusion patterns changed from '{1}' to '{2}'.", ManifestPath,
                    existingPatternString ?? "no patterns",
                    patternString ?? "no patterns");
            }
        }
        else
        {
            Log.LogMessage(MessageImportance.Low, "Generating manifest file '{0}' because manifest file is missing or out of date.", ManifestPath);
        }
 
        try
        {
            // Update exclusion patterns cache if needed
            UpdateExclusionPatternsCache(existingPatternString, patternString);
 
            // Get the list of the asset that need to be part of the manifest (this is similar to GenerateStaticWebAssetsDevelopmentManifest)
            var assets = StaticWebAsset.FromTaskItemGroup(Assets);
            var manifestAssets = ComputeManifestAssets(assets, ManifestType)
                .ToDictionary(a => a.ResolvedAsset.Identity, a => a, OSPath.PathComparer);
 
            // Build exclusion matcher if patterns are provided
            StaticWebAssetGlobMatcher exclusionMatcher = null;
            if (parsedPatterns.Length > 0)
            {
                var builder = new StaticWebAssetGlobMatcherBuilder();
                builder.AddIncludePatternsList(parsedPatterns);
                exclusionMatcher = builder.Build();
            }
 
            // Filter out the endpoints to those that point to the assets that are part of the manifest
            var endpoints = StaticWebAssetEndpoint.FromItemGroup(Endpoints);
            var filteredEndpoints = new List<StaticWebAssetEndpoint>();
            var updatedManifest = false;
            foreach (var endpoint in endpoints)
            {
                if (!manifestAssets.TryGetValue(endpoint.AssetFile, out var asset))
                {
                    Log.LogMessage(MessageImportance.Low, "Skipping endpoint '{0}' because the asset '{1}' is not part of the manifest", endpoint.Route, endpoint.AssetFile);
                    continue;
                }
 
                // Check if endpoint should be excluded based on patterns
                var route = asset.ResolvedAsset.ReplaceTokens(endpoint.Route, StaticWebAssetTokenResolver.Instance);
                if (exclusionMatcher != null)
                {
                    var match = exclusionMatcher.Match(route);
                    if (match.IsMatch)
                    {
                        if (!updatedManifest && File.Exists(ManifestPath))
                        {
                            updatedManifest = true;
                            // Touch the manifest if we are excluding endpoints to ensure we don't keep reporting out of date
                            // for the excluded endpoints.
                            // (The SWA manifest we use as cache might get updated, but if we filter out the new endpoints, we won't
                            // update the endpoints manifest file and on the next build we will re-enter this loop).
                            Log.LogMessage(MessageImportance.Low, "Updating manifest timestamp '{0}'.", ManifestPath);
                            File.SetLastWriteTime(ManifestPath, DateTime.UtcNow);
                        }
                        Log.LogMessage(MessageImportance.Low, "Excluding endpoint '{0}' based on exclusion patterns", route);
                        continue;
                    }
                }
 
                filteredEndpoints.Add(endpoint);
                // Update the endpoint to use the target path of the asset, this will be relative to the wwwroot
 
                endpoint.AssetFile = asset.ResolvedAsset.ComputeTargetPath("", '/', StaticWebAssetTokenResolver.Instance);
                endpoint.Route = route;
 
                Log.LogMessage(MessageImportance.Low, "Including endpoint '{0}' for asset '{1}' with final location '{2}'", endpoint.Route, endpoint.AssetFile, asset.TargetPath);
            }
 
            var manifest = new StaticWebAssetEndpointsManifest()
            {
                Version = 1,
                ManifestType = ManifestType,
                Endpoints = [.. filteredEndpoints]
            };
 
            this.PersistFileIfChanged(manifest, ManifestPath, StaticWebAssetsJsonSerializerContext.RelaxedEscaping.StaticWebAssetEndpointsManifest);
        }
        catch (Exception ex)
        {
            Log.LogErrorFromException(ex, showStackTrace: true, showDetail: true, null);
            return false;
        }
 
        return !Log.HasLoggedErrors;
    }
 
    private static (string, string[]) ParseAndSortPatterns(string patterns)
    {
        if (string.IsNullOrEmpty(patterns))
        {
            return (null, []);
        }
 
        var parsed = patterns.Split([';'], StringSplitOptions.RemoveEmptyEntries);
        Array.Sort(parsed, StringComparer.OrdinalIgnoreCase);
 
        return (string.Join(Environment.NewLine, parsed), parsed);
    }
 
    private void UpdateExclusionPatternsCache(string existingPatternString, string patternString)
    {
        if (string.IsNullOrEmpty(ExclusionPatternsCacheFilePath))
        {
            return;
        }
 
        if (!File.Exists(ExclusionPatternsCacheFilePath) ||
            !string.Equals(existingPatternString, patternString, StringComparison.Ordinal))
        {
            var directoryName = Path.GetDirectoryName(ExclusionPatternsCacheFilePath);
            if (directoryName != null)
            {
                Directory.CreateDirectory(directoryName);
            }
            File.WriteAllText(ExclusionPatternsCacheFilePath, patternString);
            // We need to touch the file because otherwise we will keep thinking that is out of date in the future.
            // This file might not be rewritten if the results are unchanged.
            if (File.Exists(ManifestPath))
            {
                File.SetLastWriteTime(ManifestPath, DateTime.UtcNow);
            }
        }
    }
 
    private IEnumerable<TargetPathAssetPair> ComputeManifestAssets(IEnumerable<StaticWebAsset> assets, string kind)
    {
        var assetsByTargetPath = assets
            .GroupBy(a => a.ComputeTargetPath("", '/'));
 
        foreach (var group in assetsByTargetPath)
        {
            var asset = StaticWebAsset.ChooseNearestAssetKind(group, kind).SingleOrDefault();
 
            if (asset == null)
            {
                Log.LogMessage(MessageImportance.Low, "Skipping candidate asset '{0}' because it is not a '{1}' or 'All' asset.", group.Key, kind);
                continue;
            }
 
            if (asset.HasSourceId(Source) && !StaticWebAssetsManifest.ManifestModes.ShouldIncludeAssetInCurrentProject(asset, StaticWebAssetsManifest.ManifestModes.Root))
            {
                Log.LogMessage(MessageImportance.Low, "Skipping candidate asset '{0}' because asset mode is '{1}'",
                    asset.Identity,
                    asset.AssetMode);
 
                continue;
            }
 
            yield return new TargetPathAssetPair(group.Key, asset);
        }
    }
 
    private sealed class TargetPathAssetPair(string targetPath, StaticWebAsset asset)
    {
        public string TargetPath { get; } = targetPath;
        public StaticWebAsset ResolvedAsset { get; } = asset;
    }
}