diff --git a/src/BlazorWasmSdk/Targets/Microsoft.NET.Sdk.BlazorWebAssembly.6_0.targets b/src/BlazorWasmSdk/Targets/Microsoft.NET.Sdk.BlazorWebAssembly.6_0.targets index 46e2ef8cd2d1..f632de9957f9 100644 --- a/src/BlazorWasmSdk/Targets/Microsoft.NET.Sdk.BlazorWebAssembly.6_0.targets +++ b/src/BlazorWasmSdk/Targets/Microsoft.NET.Sdk.BlazorWebAssembly.6_0.targets @@ -50,9 +50,15 @@ Copyright (c) .NET Foundation. All rights reserved. false true + <_TargetingNETBefore80>$([MSBuild]::VersionLessThan('$(TargetFrameworkVersion)', '8.0')) <_TargetingNET80OrLater>$([MSBuild]::VersionGreaterThanOrEquals('$(TargetFrameworkVersion)', '8.0')) - true - false + <_TargetingNETBefore100>$([MSBuild]::VersionLessThan('$(TargetFrameworkVersion)', '10.0')) + + + + + true + false false diff --git a/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.HtmlImportMap.targets b/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.HtmlImportMap.targets new file mode 100644 index 000000000000..e7280878381d --- /dev/null +++ b/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.HtmlImportMap.targets @@ -0,0 +1,212 @@ + + + + + + + + + + + $(ResolveBuildRelatedStaticWebAssetsDependsOn); + ResolveHtmlImportMapBuildStaticWebAssets; + + + $(ResolveCompressedFilesDependsOn); + ResolveHtmlImportMapBuildStaticWebAssets + + + $(ResolveBuildServiceWorkerStaticWebAssetsDependsOn); + ResolveHtmlImportMapBuildStaticWebAssets + + + GenerateHtmlImportMapBuildStaticWebAssets; + $(ResolveHtmlImportMapBuildStaticWebAssetsDependsOn) + + + ResolveHtmlImportMapBuildConfiguration; + $(GenerateHtmlImportMapBuildStaticWebAssetsDependsOn) + + + + + $(ResolvePublishRelatedStaticWebAssetsDependsOn); + ResolveHtmlImportMapPublishStaticWebAssets + + + $(ResolvePublishCompressedStaticWebAssetsDependsOn); + ResolveHtmlImportMapPublishStaticWebAssets + + + $(ResolvePublishServiceWorkerStaticWebAssetsDependsOn); + ResolveHtmlImportMapPublishStaticWebAssets + + + GenerateHtmlImportMapPublishStaticWebAssets; + $(ResolveHtmlImportMapPublishStaticWebAssetsDependsOn) + + + ResolveHtmlImportMapPublishConfiguration; + $(GenerateHtmlImportMapPublishStaticWebAssetsDependsOn) + + + + + + + + + <_BuildImportMapHtmlPath>$([MSBuild]::NormalizeDirectory($(_StaticWebAssetsIntermediateOutputPath), 'importmaphtml', 'build')) + + + + + + <_HtmlStaticWebAssets Include="@(StaticWebAsset)" Condition="'%(AssetKind)' != 'Publish' and '%(Extension)' == '.html'" /> + <_EsModuleCandidate Include="@(StaticWebAsset)" Condition="'%(AssetKind)' != 'Publish'" /> + + + + + + + + + + + + + + + + + + <_HtmlCandidatesNoMetadata + Include="@(_HtmlCandidates)" + RemoveMetadata="SourceType;AssetKind;Integrity;Fingerprint" /> + <_HtmlCandidatesNoMetadata ContentRoot="$(_BuildImportMapHtmlPath)" /> + + + + + + + + + + + + + + + + + + + + + + + <_PublishImportMapHtmlPath>$([MSBuild]::NormalizeDirectory($(_StaticWebAssetsIntermediateOutputPath), 'importmaphtml', 'publish')) + + + + + + <_EsModuleCandidateForPublish Include="@(StaticWebAsset)" Condition="'%(AssetKind)' != 'Build'" /> + + + + + + + + + + + + + + + + + + <_HtmlPublishCandidatesNoMetadata + Include="@(_HtmlPublishCandidates)" + RemoveMetadata="SourceType;AssetKind;Integrity;Fingerprint" /> + <_HtmlPublishCandidatesNoMetadata ContentRoot="$(_PublishImportMapHtmlPath)" /> + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.targets b/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.targets index c4c9e9fea131..2b947f8e62b7 100644 --- a/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.targets +++ b/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.targets @@ -737,5 +737,7 @@ Copyright (c) .NET Foundation. All rights reserved. + + diff --git a/src/StaticWebAssetsSdk/Tasks/Utils/ArtifactWriter.cs b/src/StaticWebAssetsSdk/Tasks/Utils/ArtifactWriter.cs index 76b2feef2ac3..ad03c44a871c 100644 --- a/src/StaticWebAssetsSdk/Tasks/Utils/ArtifactWriter.cs +++ b/src/StaticWebAssetsSdk/Tasks/Utils/ArtifactWriter.cs @@ -10,9 +10,14 @@ namespace Microsoft.AspNetCore.StaticWebAssets.Tasks.Utils; public static class ArtifactWriter { - public static void PersistFileIfChanged(this Task task, T manifest, string artifactPath, JsonTypeInfo serializer) + public static bool PersistFileIfChanged(this Task task, T manifest, string artifactPath, JsonTypeInfo serializer) { var data = JsonSerializer.SerializeToUtf8Bytes(manifest, serializer); + return PersistFileIfChanged(task, data, artifactPath); + } + + public static bool PersistFileIfChanged(this Task task, byte[] data, string artifactPath) + { var newHash = ComputeHash(data); var fileExists = File.Exists(artifactPath); var existingManifestHash = fileExists ? ComputeHash(artifactPath) : null; @@ -21,15 +26,18 @@ public static void PersistFileIfChanged(this Task task, T manifest, string ar { task.Log.LogMessage(MessageImportance.Low, $"Creating artifact because artifact file '{artifactPath}' does not exist."); File.WriteAllBytes(artifactPath, data); + return true; } else if (!string.Equals(newHash, existingManifestHash, StringComparison.Ordinal)) { task.Log.LogMessage(MessageImportance.Low, $"Updating artifact because artifact version '{newHash}' is different from existing artifact hash '{existingManifestHash}'."); File.WriteAllBytes(artifactPath, data); + return true; } else { task.Log.LogMessage(MessageImportance.Low, $"Skipping artifact updated because artifact version '{existingManifestHash}' has not changed."); + return false; } } diff --git a/src/StaticWebAssetsSdk/Tasks/WriteImportMapToHtml.cs b/src/StaticWebAssetsSdk/Tasks/WriteImportMapToHtml.cs new file mode 100644 index 000000000000..1b9cf0f4316a --- /dev/null +++ b/src/StaticWebAssetsSdk/Tasks/WriteImportMapToHtml.cs @@ -0,0 +1,207 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.RegularExpressions; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; +using System.Text.Encodings.Web; +using System.Text.Json.Serialization; +using System.Text.Json; +using Microsoft.AspNetCore.StaticWebAssets.Tasks.Utils; + +namespace Microsoft.AspNetCore.StaticWebAssets.Tasks; + +public partial class WriteImportMapToHtml : Task +{ + [Required] + public ITaskItem[] Endpoints { 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; } = []; + + // "([^"]+)(#\[\.{fingerprint}\])([^"]+)" + // 1.group = file name + // 2.group = fingerprint placeholder + // 3.group = file extension + // wrapped in quotes + private static readonly Regex _assetsRegex = new Regex(@"""([^""]+)(#\[\.{fingerprint}\])([^""]+)"""); + + private static readonly Regex _importMapRegex = new Regex(@"\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(); + var htmlCandidates = new List(); + var fileWrites = new List(); + + 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 $""; + }); + + // Fingerprint all assets used in html + outputContent = _assetsRegex.Replace(outputContent, e => + { + string assetPath = e.Groups[1].Value + e.Groups[3].Value; + string fingerprintedAssetPath = urlMappings.TryGetValue(assetPath, out var value) ? value.Url : 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")); + if (this.PersistFileIfChanged(Encoding.UTF8.GetBytes(outputContent), outputPath)) + { + fileWrites.Add(outputPath); + } + + htmlCandidates.Add(new TaskItem(outputPath, item.CloneCustomMetadata())); + } + } + } + + HtmlCandidates = htmlCandidates.ToArray(); + HtmlFilesToRemove = htmlFilesToRemove.ToArray(); + FileWrites = fileWrites.ToArray(); + return true; + } + + internal static List CreateResourcesFromEndpoints(IEnumerable endpoints) + { + var resources = new List(); + + // 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 descriptor in endpoints) + { + string? label = null; + string? integrity = null; + + // If there's a selector this means that this is an alternative representation for a resource, so skip it. + if (descriptor.Selectors?.Length == 0) + { + for (var i = 0; i < descriptor.EndpointProperties?.Length; i++) + { + var property = descriptor.EndpointProperties[i]; + if (property.Name.Equals("label", StringComparison.OrdinalIgnoreCase)) + { + label = property.Value; + } + else if (property.Name.Equals("integrity", StringComparison.OrdinalIgnoreCase)) + { + integrity = property.Value; + } + } + + resources.Add(new ResourceAsset(descriptor.Route, label, integrity)); + } + } + + return resources; + } + + private static ImportMap CreateImportMapFromResources(List assets) + { + Dictionary? imports = new(); + Dictionary>? scopes = new(); ; + Dictionary? integrity = new(); + + foreach (var asset in assets) + { + if (asset.Integrity != null) + { + integrity ??= []; + integrity[$"./{asset.Url}"] = asset.Integrity; + } + + if (asset.Label != null) + { + imports ??= []; + imports[$"./{asset.Label}"] = $"./{asset.Url}"; + } + } + + return new ImportMap(imports, scopes, integrity); + } + + private static Dictionary GroupResourcesByLabel(List resources) + { + var mappings = new Dictionary(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, string? label, string? integrity) +{ + public string Url { get; } = url; + public string? Label { get; set; } = label; + public string? Integrity { get; set; } = integrity; +} + +internal class ImportMap(Dictionary imports, Dictionary> scopes, Dictionary integrity) +{ + public Dictionary Imports { get; set; } = imports; + public Dictionary> Scopes { get; set; } = scopes; + public Dictionary 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, + }); +} diff --git a/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssetsFingerprintingTest.cs b/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssetsFingerprintingTest.cs index fbeaa3c4e2a7..10e90ba8d34b 100644 --- a/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssetsFingerprintingTest.cs +++ b/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssetsFingerprintingTest.cs @@ -4,6 +4,7 @@ #nullable disable using Microsoft.AspNetCore.StaticWebAssets.Tasks; +using System.Text.Json; namespace Microsoft.NET.Sdk.Razor.Tests; @@ -44,4 +45,64 @@ public void Build_FingerprintsContent_WhenEnabled() AssertManifest(manifest1, expectedManifest); AssertBuildAssets(manifest1, outputPath, intermediateOutputPath); } + + public static TheoryData WriteImportMapToHtmlData => new TheoryData + { + { "VanillaWasm", true }, + { "BlazorWasmMinimal", false } + }; + + [Theory] + [MemberData(nameof(WriteImportMapToHtmlData))] + public void Build_WriteImportMapToHtml(string testAsset, bool assetMainJs) + { + ProjectDirectory = CreateAspNetSdkTestAsset(testAsset); + + var build = CreateBuildCommand(ProjectDirectory); + ExecuteCommand(build, "-p:WriteImportMapToHtml=true").Should().Pass(); + + var intermediateOutputPath = build.GetIntermediateDirectory(DefaultTfm, "Debug").ToString(); + var indexHtmlPath = Directory.EnumerateFiles(Path.Combine(intermediateOutputPath, "staticwebassets", "importmaphtml", "build"), "*.html").Single(); + var endpointsManifestPath = Path.Combine(intermediateOutputPath, $"staticwebassets.build.endpoints.json"); + + AssertImportMapInHtml(indexHtmlPath, endpointsManifestPath, assetMainJs); + } + + [Theory] + [MemberData(nameof(WriteImportMapToHtmlData))] + public void Publish_WriteImportMapToHtml(string testAsset, bool assetMainJs) + { + ProjectDirectory = CreateAspNetSdkTestAsset(testAsset); + + var projectName = Path.GetFileNameWithoutExtension(Directory.EnumerateFiles(ProjectDirectory.TestRoot, "*.csproj").Single()); + + var publish = CreatePublishCommand(ProjectDirectory); + ExecuteCommand(publish, "-p:WriteImportMapToHtml=true").Should().Pass(); + + var outputPath = publish.GetOutputDirectory(DefaultTfm, "Debug").ToString(); + var indexHtmlPath = Path.Combine(outputPath, "wwwroot", "index.html"); + var endpointsManifestPath = Path.Combine(outputPath, $"{projectName}.staticwebassets.endpoints.json"); + + AssertImportMapInHtml(indexHtmlPath, endpointsManifestPath, assetMainJs); + } + + private void AssertImportMapInHtml(string indexHtmlPath, string endpointsManifestPath, bool assetMainJs) + { + var indexHtmlContent = File.ReadAllText(indexHtmlPath); + var endpoints = JsonSerializer.Deserialize(File.ReadAllText(endpointsManifestPath)); + + if (assetMainJs) + { + var mainJs = GetFingerprintedPath("main.js"); + Assert.DoesNotContain("src=\"main.js\"", indexHtmlContent); + Assert.Contains($"src=\"{mainJs}\"", indexHtmlContent); + } + + Assert.Contains(GetFingerprintedPath("_framework/dotnet.js"), indexHtmlContent); + Assert.Contains(GetFingerprintedPath("_framework/dotnet.native.js"), indexHtmlContent); + Assert.Contains(GetFingerprintedPath("_framework/dotnet.runtime.js"), indexHtmlContent); + + string GetFingerprintedPath(string route) + => endpoints.Endpoints.FirstOrDefault(e => e.Route == route && e.Selectors.Length == 0)?.AssetFile ?? throw new Exception($"Missing endpoint for file '{route}'"); + } } diff --git a/test/TestAssets/TestProjects/BlazorWasmMinimal/wwwroot/index.html b/test/TestAssets/TestProjects/BlazorWasmMinimal/wwwroot/index.html index bbfa66c41abc..137d7b54777f 100644 --- a/test/TestAssets/TestProjects/BlazorWasmMinimal/wwwroot/index.html +++ b/test/TestAssets/TestProjects/BlazorWasmMinimal/wwwroot/index.html @@ -8,6 +8,7 @@ + diff --git a/test/TestAssets/TestProjects/VanillaWasm/VanillaWasm.csproj b/test/TestAssets/TestProjects/VanillaWasm/VanillaWasm.csproj index 9676d0bfc24d..5f59d884b5f7 100644 --- a/test/TestAssets/TestProjects/VanillaWasm/VanillaWasm.csproj +++ b/test/TestAssets/TestProjects/VanillaWasm/VanillaWasm.csproj @@ -3,4 +3,7 @@ $(CurrentTargetFramework) true + + + diff --git a/test/TestAssets/TestProjects/VanillaWasm/wwwroot/index.html b/test/TestAssets/TestProjects/VanillaWasm/wwwroot/index.html index e9f8422826d3..32bb2959ea5f 100644 --- a/test/TestAssets/TestProjects/VanillaWasm/wwwroot/index.html +++ b/test/TestAssets/TestProjects/VanillaWasm/wwwroot/index.html @@ -7,7 +7,9 @@ WasmMySdk - + + +