File: OverrideHtmlAssetPlaceholders.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.
 
using Microsoft.AspNetCore.StaticWebAssets.Tasks.Utils;
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;
using System.Text.Encodings.Web;
using System.Text.Json.Serialization;
using System.Text.Json;
using System.Text.RegularExpressions;
 
namespace Microsoft.AspNetCore.StaticWebAssets.Tasks;
 
public partial class OverrideHtmlAssetPlaceholders : Task
{
    [Required]
    public ITaskItem[] Assets { get; set; } = [];
 
    [Required]
    public ITaskItem[] Endpoints { get; set; } = [];
 
    [Required]
    public bool IncludeOnlyHardFingerprintedModules { get; set; }
 
    [Required]
    public string OutputPath { get; set; } = string.Empty;
 
    [Required]
    public ITaskItem[] HtmlFiles { get; set; } = [];
 
    [Output]
    public ITaskItem[] HtmlCandidates { get; set; } = [];
 
    [Output]
    public ITaskItem[] HtmlFilesToRemove { get; set; } = [];
 
    [Output]
    public string[] FileWrites { get; set; } = [];
 
    internal static readonly Regex _assetsRegex = new Regex(@"""(?<fileName>[^""]+)#\[\.{fingerprint}\](?<fileExtension>[^""]+)""");
 
    internal static readonly Regex _importMapRegex = new Regex(@"<script\s+type=""importmap""\s*>\s*</script>");
 
    internal static readonly Regex _preloadRegex = new Regex(@"<link\s+rel=""preload""(\s+id=""(?<group>[^""]+)"")?\s*[/]?>");
 
    public override bool Execute()
    {
        var endpoints = StaticWebAssetEndpoint.FromItemGroup(Endpoints).Where(e => e.AssetFile.EndsWith(".js") || e.AssetFile.EndsWith(".mjs"));
        var resources = CreateResourcesFromEndpoints(endpoints);
        var urlMappings = GroupResourcesByLabel(resources);
        var importMap = CreateImportMapFromResources(resources);
 
        var htmlFilesToRemove = new List<ITaskItem>();
        var htmlCandidates = new List<ITaskItem>();
        var fileWrites = new List<string>();
 
        if (!Directory.Exists(OutputPath))
        {
            Directory.CreateDirectory(OutputPath);
        }
 
        foreach (var item in HtmlFiles)
        {
            if (File.Exists(item.ItemSpec))
            {
                string content = File.ReadAllText(item.ItemSpec);
 
                // Generate import map
                string outputContent = _importMapRegex.Replace(content, e =>
                {
                    Log.LogMessage("Writing importmap to '{0}'", item.ItemSpec);
                    return $"<script type=\"importmap\">{JsonSerializer.Serialize(importMap, ImportMapSerializerContext.CustomEncoder.Options)}</script>";
                });
 
                // Generate import map
                outputContent = _preloadRegex.Replace(outputContent, e =>
                {
                    Log.LogMessage("Writing preload links to '{0}'", item.ItemSpec);
                    return GeneratePreloadLinks(resources, e.Groups["group"]?.Value);
                });
 
                // Fingerprint all assets used in html
                outputContent = _assetsRegex.Replace(outputContent, e =>
                {
                    string assetPath = e.Groups["fileName"].Value + e.Groups["fileExtension"].Value;
                    string fingerprintedAssetPath = GetFingerprintedAssetPath(urlMappings, assetPath);
                    Log.LogMessage("Replacing asset '{0}' with fingerprinted version '{1}'", assetPath, fingerprintedAssetPath);
                    return "\"" + fingerprintedAssetPath + "\"";
                });
 
                if (content != outputContent)
                {
                    htmlFilesToRemove.Add(item);
 
                    string outputPath = Path.Combine(OutputPath, FileHasher.HashString(item.ItemSpec) + item.GetMetadata("Extension"));
                    this.PersistFileIfChanged(Encoding.UTF8.GetBytes(outputContent), outputPath);
                    fileWrites.Add(outputPath);
 
                    var newItem = new TaskItem(outputPath, item.CloneCustomMetadata());
                    newItem.RemoveMetadata("OriginalItemSpec");
                    htmlCandidates.Add(newItem);
                }
            }
        }
 
        HtmlCandidates = htmlCandidates.ToArray();
        HtmlFilesToRemove = htmlFilesToRemove.ToArray();
        FileWrites = fileWrites.ToArray();
        return true;
    }
 
    private static string GeneratePreloadLinks(List<ResourceAsset> assets, string? group)
    {
        var links = new List<(int Order, string Value)>();
        foreach (var asset in assets)
        {
            if (asset.PreloadRel == null)
            {
                continue;
            }
 
            if (group != null && asset.PreloadGroup != group)
            {
                continue;
            }
 
            var link = new StringBuilder();
            link.Append($"<link href=\"").Append(asset.Url).Append("\" rel=\"").Append(asset.PreloadRel).Append('"');
            if (!string.IsNullOrEmpty(asset.PreloadAs))
            {
                link.Append(" as=\"").Append(asset.PreloadAs).Append('"');
            }
            if (!string.IsNullOrEmpty(asset.PreloadPriority))
            {
                link.Append(" fetchpriority=\"").Append(asset.PreloadPriority).Append('"');
            }
            if (!string.IsNullOrEmpty(asset.PreloadCrossorigin))
            {
                link.Append(" crossorigin=\"").Append(asset.PreloadCrossorigin).Append('"');
            }
            if (!string.IsNullOrEmpty(asset.Integrity))
            {
                link.Append(" integrity=\"").Append(asset.Integrity).Append('"');
            }
 
            link.Append(" />");
            links.Add((asset.PreloadOrder, link.ToString()));
        }
 
        links.Sort((a, b) => a.Order.CompareTo(b.Order));
        return String.Join(Environment.NewLine, links.Select(l => l.Value));
    }
 
    private string GetFingerprintedAssetPath(Dictionary<string, ResourceAsset> urlMappings, string assetPath)
    {
        if (urlMappings.TryGetValue(assetPath, out var asset) && (!IncludeOnlyHardFingerprintedModules || asset.IsHardFingerprinted))
        {
            return asset.Url;
        }
 
        return assetPath;
    }
 
    internal List<ResourceAsset> CreateResourcesFromEndpoints(IEnumerable<StaticWebAssetEndpoint> endpoints)
    {
        var resources = new List<ResourceAsset>();
 
        // We are converting a subset of the descriptors to resources and including a subset of the properties exposed by the
        // descriptors that are useful for the resources in the context of Blazor. Specifically, we pass in the `label` property
        // which contains the human-readable identifier for fingerprinted assets, and the integrity, which can be used to apply
        // subresource integrity to things like images, script tags, etc.
        foreach (var endpoint in endpoints)
        {
            // If there's a selector this means that this is an alternative representation for a resource, so skip it.
            if (endpoint.Selectors?.Length == 0)
            {
                var resourceAsset = new ResourceAsset(endpoint.Route);
                for (var i = 0; i < endpoint.EndpointProperties?.Length; i++)
                {
                    var property = endpoint.EndpointProperties[i];
                    if (property.Name.Equals("label", StringComparison.OrdinalIgnoreCase))
                    {
                        resourceAsset.Label = property.Value;
                    }
                    else if (property.Name.Equals("integrity", StringComparison.OrdinalIgnoreCase))
                    {
                        resourceAsset.Integrity = property.Value;
                    }
                    else if (property.Name.Equals("preloadgroup", StringComparison.OrdinalIgnoreCase))
                    {
                        resourceAsset.PreloadGroup = property.Value;
                    }
                    else if (property.Name.Equals("preloadrel", StringComparison.OrdinalIgnoreCase))
                    {
                        resourceAsset.PreloadRel = property.Value;
                    }
                    else if (property.Name.Equals("preloadas", StringComparison.OrdinalIgnoreCase))
                    {
                        resourceAsset.PreloadAs = property.Value;
                    }
                    else if (property.Name.Equals("preloadpriority", StringComparison.OrdinalIgnoreCase))
                    {
                        resourceAsset.PreloadPriority = property.Value;
                    }
                    else if (property.Name.Equals("preloadcrossorigin", StringComparison.OrdinalIgnoreCase))
                    {
                        resourceAsset.PreloadCrossorigin = property.Value;
                    }
                    else if (property.Name.Equals("preloadorder", StringComparison.OrdinalIgnoreCase))
                    {
                        if (!int.TryParse(property.Value, out int order))
                        {
                            order = 0;
                        }
 
                        resourceAsset.PreloadOrder = order;
                    }
                }
 
                var asset = Assets.FirstOrDefault(a => a.ItemSpec == endpoint.AssetFile);
                if (asset != null)
                {
                    resourceAsset.IsHardFingerprinted = asset.GetMetadata("RelativePath").Contains("#[.{fingerprint}]!");
                }
 
                resources.Add(resourceAsset);
            }
        }
 
        return resources;
    }
 
    private ImportMap CreateImportMapFromResources(List<ResourceAsset> assets)
    {
        Dictionary<string, string>? imports = new();
        Dictionary<string, Dictionary<string, string>>? scopes = new();
        Dictionary<string, string>? integrity = new();
 
        foreach (var asset in assets)
        {
            if (IncludeOnlyHardFingerprintedModules && !asset.IsHardFingerprinted)
            {
                continue;
            }
 
            if (asset.Integrity != null)
            {
                integrity ??= [];
                integrity[$"./{asset.Url}"] = asset.Integrity;
            }
 
            // Only fingerprinted assets have label
            if (asset.Label != null)
            {
                imports ??= [];
                imports[$"./{asset.Label}"] = $"./{asset.Url}";
            }
        }
 
        return new ImportMap(imports, scopes, integrity);
    }
 
    private static Dictionary<string, ResourceAsset> GroupResourcesByLabel(List<ResourceAsset> resources)
    {
        var mappings = new Dictionary<string, ResourceAsset>(StringComparer.OrdinalIgnoreCase);
        foreach (var resource in resources)
        {
            if (resource.Label != null)
            {
                if (mappings.TryGetValue(resource.Label, out var value))
                {
                    throw new InvalidOperationException($"The static asset '{resource.Label}' is already mapped to {value.Url}.");
                }
                mappings[resource.Label] = resource;
            }
        }
 
        return mappings;
    }
}
 
internal sealed class ResourceAsset(string url)
{
    public string Url { get; } = url;
    public string? Label { get; set; }
    public string? Integrity { get; set; }
    public string? PreloadGroup { get; set; }
    public string? PreloadRel { get; set; }
    public string? PreloadAs { get; set; }
    public string? PreloadPriority { get; set; }
    public string? PreloadCrossorigin { get; set; }
    public int PreloadOrder { get; set; }
    public bool IsHardFingerprinted { get; set; } = true;
}
 
internal class ImportMap(Dictionary<string, string> imports, Dictionary<string, Dictionary<string, string>> scopes, Dictionary<string, string> integrity)
{
    public Dictionary<string, string> Imports { get; set; } = imports;
    public Dictionary<string, Dictionary<string, string>> Scopes { get; set; } = scopes;
    public Dictionary<string, string> Integrity { get; set; } = integrity;
}
 
[JsonSerializable(typeof(ImportMap))]
internal sealed partial class ImportMapSerializerContext : JsonSerializerContext
{
    private static ImportMapSerializerContext? _customEncoder;
 
    public static ImportMapSerializerContext CustomEncoder => _customEncoder ??= new(new JsonSerializerOptions
    {
        WriteIndented = true,
        Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    });
}