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(@"");
+
+ 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
-
+
+
+