diff --git a/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.HtmlImportMap.targets b/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.HtmlAssetPlaceholders.targets similarity index 64% rename from src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.HtmlImportMap.targets rename to src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.HtmlAssetPlaceholders.targets index 579338938557..dee61365fd50 100644 --- a/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.HtmlImportMap.targets +++ b/src/StaticWebAssetsSdk/Targets/Microsoft.NET.Sdk.StaticWebAssets.HtmlAssetPlaceholders.targets @@ -1,6 +1,6 @@ + true + $(ResolveBuildRelatedStaticWebAssetsDependsOn); - ResolveHtmlImportMapBuildStaticWebAssets; + ResolveHtmlAssetPlaceholdersBuildStaticWebAssets; $(ResolveCompressedFilesDependsOn); - ResolveHtmlImportMapBuildStaticWebAssets + ResolveHtmlAssetPlaceholdersBuildStaticWebAssets $(ResolveBuildServiceWorkerStaticWebAssetsDependsOn); - ResolveHtmlImportMapBuildStaticWebAssets + ResolveHtmlAssetPlaceholdersBuildStaticWebAssets - - GenerateHtmlImportMapBuildStaticWebAssets; - $(ResolveHtmlImportMapBuildStaticWebAssetsDependsOn) - - - ResolveHtmlImportMapBuildConfiguration; - $(GenerateHtmlImportMapBuildStaticWebAssetsDependsOn) - + + $(ResolveHtmlAssetPlaceholdersBuildStaticWebAssetsDependsOn); + GenerateHtmlAssetPlaceholdersBuildStaticWebAssets + + + $(GenerateHtmlAssetPlaceholdersBuildStaticWebAssetsDependsOn); + ResolveHtmlAssetPlaceholdersBuildConfiguration + $(ResolvePublishRelatedStaticWebAssetsDependsOn); - ResolveHtmlImportMapPublishStaticWebAssets + ResolveHtmlAssetPlaceholdersPublishStaticWebAssets $(ResolvePublishCompressedStaticWebAssetsDependsOn); - ResolveHtmlImportMapPublishStaticWebAssets + ResolveHtmlAssetPlaceholdersPublishStaticWebAssets $(ResolvePublishServiceWorkerStaticWebAssetsDependsOn); - ResolveHtmlImportMapPublishStaticWebAssets + ResolveHtmlAssetPlaceholdersPublishStaticWebAssets - - GenerateHtmlImportMapPublishStaticWebAssets; - $(ResolveHtmlImportMapPublishStaticWebAssetsDependsOn) - - - ResolveHtmlImportMapPublishConfiguration; - $(GenerateHtmlImportMapPublishStaticWebAssetsDependsOn) - + + $(ResolveHtmlAssetPlaceholdersPublishStaticWebAssetsDependsOn); + GenerateHtmlAssetPlaceholdersPublishStaticWebAssets + + + $(GenerateHtmlAssetPlaceholdersPublishStaticWebAssetsDependsOn); + ResolveHtmlAssetPlaceholdersPublishConfiguration + - + - <_BuildImportMapHtmlPath>$([MSBuild]::NormalizeDirectory($(_StaticWebAssetsIntermediateOutputPath), 'importmaphtml', 'build')) + <_BuildHtmlAssetPlaceholdersPath>$([MSBuild]::NormalizeDirectory($(_StaticWebAssetsIntermediateOutputPath), 'htmlassetplaceholders', 'build')) - + <_HtmlStaticWebAssets Include="@(StaticWebAsset)" Condition="'%(AssetKind)' != 'Publish' and '%(Extension)' == '.html'" /> @@ -95,25 +100,25 @@ Copyright (c) .NET Foundation. All rights reserved. - - + + OutputPath="$(_BuildHtmlAssetPlaceholdersPath)"> - + - + <_HtmlCandidatesNoMetadata Include="@(_HtmlCandidates)" RemoveMetadata="SourceType;AssetKind;Integrity;Fingerprint" /> - <_HtmlCandidatesNoMetadata ContentRoot="$(_BuildImportMapHtmlPath)" /> + <_HtmlCandidatesNoMetadata ContentRoot="$(_BuildHtmlAssetPlaceholdersPath)" /> - + - <_PublishImportMapHtmlPath>$([MSBuild]::NormalizeDirectory($(_StaticWebAssetsIntermediateOutputPath), 'importmaphtml', 'publish')) + <_PublishHtmlAssetPlaceholdersPath>$([MSBuild]::NormalizeDirectory($(_StaticWebAssetsIntermediateOutputPath), 'htmlassetplaceholders', 'publish')) - + <_EsModuleCandidateForPublish Include="@(StaticWebAsset)" Condition="'%(AssetKind)' != 'Build'" /> @@ -165,25 +170,25 @@ Copyright (c) .NET Foundation. All rights reserved. - - + + OutputPath="$(_PublishHtmlAssetPlaceholdersPath)"> - + - + <_HtmlPublishCandidatesNoMetadata Include="@(_HtmlPublishCandidates)" RemoveMetadata="SourceType;AssetKind;Integrity;Fingerprint" /> - <_HtmlPublishCandidatesNoMetadata ContentRoot="$(_PublishImportMapHtmlPath)" /> + <_HtmlPublishCandidatesNoMetadata ContentRoot="$(_PublishHtmlAssetPlaceholdersPath)" /> - + diff --git a/src/StaticWebAssetsSdk/Tasks/WriteImportMapToHtml.cs b/src/StaticWebAssetsSdk/Tasks/OverrideHtmlAssetPlaceholders.cs similarity index 62% rename from src/StaticWebAssetsSdk/Tasks/WriteImportMapToHtml.cs rename to src/StaticWebAssetsSdk/Tasks/OverrideHtmlAssetPlaceholders.cs index a7478f89018c..e69a194c6407 100644 --- a/src/StaticWebAssetsSdk/Tasks/WriteImportMapToHtml.cs +++ b/src/StaticWebAssetsSdk/Tasks/OverrideHtmlAssetPlaceholders.cs @@ -1,17 +1,17 @@ // 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.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 Microsoft.AspNetCore.StaticWebAssets.Tasks.Utils; +using System.Text.RegularExpressions; namespace Microsoft.AspNetCore.StaticWebAssets.Tasks; -public partial class WriteImportMapToHtml : Task +public partial class OverrideHtmlAssetPlaceholders : Task { [Required] public ITaskItem[] Assets { get; set; } = []; @@ -37,14 +37,11 @@ public partial class WriteImportMapToHtml : Task [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}\])([^""]+)"""); + internal static readonly Regex _assetsRegex = new Regex(@"""(?[^""]+)#\[\.{fingerprint}\](?[^""]+)"""); + + internal static readonly Regex _importMapRegex = new Regex(@"\s*"); - private static readonly Regex _importMapRegex = new Regex(@"\s*"); + internal static readonly Regex _preloadRegex = new Regex(@"[^""]+)"")?\s*[/]?>"); public override bool Execute() { @@ -75,10 +72,17 @@ public override bool Execute() return $""; }); + // 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[1].Value + e.Groups[3].Value; + 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 + "\""; @@ -105,6 +109,48 @@ public override bool Execute() return true; } + private static string GeneratePreloadLinks(List 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($""); + 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 urlMappings, string assetPath) { if (urlMappings.TryGetValue(assetPath, out var asset) && (!IncludeOnlyHardFingerprintedModules || asset.IsHardFingerprinted)) @@ -125,33 +171,59 @@ internal List CreateResourcesFromEndpoints(IEnumerable a.ItemSpec == endpoint.AssetFile); if (asset != null) { - isHardFingerprinted = asset.GetMetadata("RelativePath").Contains("#[.{fingerprint}]!"); + resourceAsset.IsHardFingerprinted = asset.GetMetadata("RelativePath").Contains("#[.{fingerprint}]!"); } - resources.Add(new ResourceAsset(endpoint.Route, label, integrity, isHardFingerprinted)); + resources.Add(resourceAsset); } } @@ -207,12 +279,18 @@ private static Dictionary GroupResourcesByLabel(List imports, Dictionary> scopes, Dictionary integrity) diff --git a/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/OverrideHtmlAssetPlaceholdersTest.cs b/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/OverrideHtmlAssetPlaceholdersTest.cs new file mode 100644 index 000000000000..31e709b85f12 --- /dev/null +++ b/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssets/OverrideHtmlAssetPlaceholdersTest.cs @@ -0,0 +1,290 @@ +// 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; +using System.Text.RegularExpressions; + +namespace Microsoft.AspNetCore.Razor.Tasks; + +public class OverrideHtmlAssetPlaceholdersTest +{ + [Theory] + [InlineData( + """ + + """, + true, + "main.js" + )] + [InlineData( + """ + + """, + true, + "main.js" + )] + [InlineData( + """ + + """, + true, + "main.js" + )] + [InlineData( + """ + + """, + true, + "./main.js" + )] + [InlineData( + """ + + """, + true, + "./folder/folder/file.name.something.js" + )] + [InlineData( + """ + + """, + true, + "main.suffix.js" + )] + [InlineData( + """ + + """, + true, + "/root/main.suffix.js" + )] + [InlineData( + """ + + """, + false + )] + [InlineData( + """ + + """, + false + )] + [InlineData( + """ + + """, + false + )] + [InlineData( + """ +

main#[.{fingerprint}].js

+ """, + false + )] + [InlineData( + """ + + """, + true, + "main.js" + )] + [InlineData( + """ + + """, + true, + "./main.js" + )] + [InlineData( + """ + + """, + true, + "main.js" + )] + public void ValidateAssetsRegex(string input, bool shouldMatch, string fileName = null) + { + var match = OverrideHtmlAssetPlaceholders._assetsRegex.Match(input); + Assert.Equal(shouldMatch, match.Success); + + if (fileName != null) + { + Assert.Equal(fileName, match.Groups["fileName"].Value + match.Groups["fileExtension"].Value); + } + } + + [Theory] + [InlineData( + """ + + """, + true + )] + [InlineData( + """ + + """, + true + )] + [InlineData( + """ + + """, + true + )] + [InlineData( + """ + + """, + true + )] + [InlineData( + """ + + """, + false + )] + [InlineData( + """ + + """, + false + )] + [InlineData( + """ + + """, + false + )] + public void ValidateImportMapRegex(string input, bool shouldMatch) + { + Assert.Equal(shouldMatch, OverrideHtmlAssetPlaceholders._importMapRegex.Match(input).Success); + } + + [Theory] + [InlineData( + """ + + """, + true + )] + [InlineData( + """ + + """, + true + )] + [InlineData( + """ + + """, + true + )] + [InlineData( + """ + + """, + false + )] + [InlineData( + """ + + """, + false + )] + [InlineData( + """ + " + """, + false + )] + [InlineData( + """ + " + """, + false + )] + [InlineData( + """ + + """, + false + )] + [InlineData( + """ + + """, + true, + "webassembly" + )] + [InlineData( + """ + + """, + true, + "webassembly" + )] + [InlineData( + """ + + """, + false + )] + [InlineData( + """ + + """, + false + )] + [InlineData( + """ + + """, + false + )] + [InlineData( + """ + + """, + true, + "webassembly" + )] + public void ValidatePreloadRegex(string input, bool shouldMatch, string group = null) + { + var match = OverrideHtmlAssetPlaceholders._preloadRegex.Match(input); + Assert.Equal(shouldMatch, match.Success); + + if (group != null) + { + Assert.Equal(group, match.Groups["group"]?.Value); + } + } +} diff --git a/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssetsFingerprintingTest.cs b/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssetsFingerprintingTest.cs index feb337d3c2c2..15438446676a 100644 --- a/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssetsFingerprintingTest.cs +++ b/test/Microsoft.NET.Sdk.Razor.Tests/StaticWebAssetsFingerprintingTest.cs @@ -46,7 +46,7 @@ public void Build_FingerprintsContent_WhenEnabled() AssertBuildAssets(manifest1, outputPath, intermediateOutputPath); } - public static TheoryData WriteImportMapToHtmlData => new TheoryData + public static TheoryData OverrideHtmlAssetPlaceholdersData => new TheoryData { { "VanillaWasm", "main.js", "main#[.{fingerprint}].js", true, true }, { "VanillaWasm", "main.js", null, false, false }, @@ -54,41 +54,41 @@ public void Build_FingerprintsContent_WhenEnabled() }; [Theory] - [MemberData(nameof(WriteImportMapToHtmlData))] - public void Build_WriteImportMapToHtml(string testAsset, string scriptPath, string scriptPathWithFingerprintPattern, bool fingerprintUserJavascriptAssets, bool expectFingerprintOnScript) + [MemberData(nameof(OverrideHtmlAssetPlaceholdersData))] + public void Build_OverrideHtmlAssetPlaceholders(string testAsset, string scriptPath, string scriptPathWithFingerprintPattern, bool fingerprintUserJavascriptAssets, bool expectFingerprintOnScript) { - ProjectDirectory = CreateAspNetSdkTestAsset(testAsset); + ProjectDirectory = CreateAspNetSdkTestAsset(testAsset, identifier: $"{testAsset}_{fingerprintUserJavascriptAssets}_{expectFingerprintOnScript}"); ReplaceStringInIndexHtml(ProjectDirectory, scriptPath, scriptPathWithFingerprintPattern); FingerprintUserJavascriptAssets(fingerprintUserJavascriptAssets); var build = CreateBuildCommand(ProjectDirectory); - ExecuteCommand(build, "-p:WriteImportMapToHtml=true", $"-p:FingerprintUserJavascriptAssets={fingerprintUserJavascriptAssets}").Should().Pass(); + ExecuteCommand(build, "-p:OverrideHtmlAssetPlaceholders=true", $"-p:FingerprintUserJavascriptAssets={fingerprintUserJavascriptAssets.ToString().ToLower()}").Should().Pass(); var intermediateOutputPath = build.GetIntermediateDirectory(DefaultTfm, "Debug").ToString(); - var indexHtmlPath = Directory.EnumerateFiles(Path.Combine(intermediateOutputPath, "staticwebassets", "importmaphtml", "build"), "*.html").Single(); + var indexHtmlPath = Directory.EnumerateFiles(Path.Combine(intermediateOutputPath, "staticwebassets", "htmlassetplaceholders", "build"), "*.html").Single(); var endpointsManifestPath = Path.Combine(intermediateOutputPath, $"staticwebassets.build.endpoints.json"); - AssertImportMapInHtml(indexHtmlPath, endpointsManifestPath, scriptPath, expectFingerprintOnScript: expectFingerprintOnScript); + AssertImportMapInHtml(indexHtmlPath, endpointsManifestPath, scriptPath, expectFingerprintOnScript: expectFingerprintOnScript, expectPreloadElement: testAsset == "VanillaWasm"); } [Theory] - [MemberData(nameof(WriteImportMapToHtmlData))] - public void Publish_WriteImportMapToHtml(string testAsset, string scriptPath, string scriptPathWithFingerprintPattern, bool fingerprintUserJavascriptAssets, bool expectFingerprintOnScript) + [MemberData(nameof(OverrideHtmlAssetPlaceholdersData))] + public void Publish_OverrideHtmlAssetPlaceholders(string testAsset, string scriptPath, string scriptPathWithFingerprintPattern, bool fingerprintUserJavascriptAssets, bool expectFingerprintOnScript) { - ProjectDirectory = CreateAspNetSdkTestAsset(testAsset); + ProjectDirectory = CreateAspNetSdkTestAsset(testAsset, identifier: $"{testAsset}_{fingerprintUserJavascriptAssets}_{expectFingerprintOnScript}"); ReplaceStringInIndexHtml(ProjectDirectory, scriptPath, scriptPathWithFingerprintPattern); FingerprintUserJavascriptAssets(fingerprintUserJavascriptAssets); var projectName = Path.GetFileNameWithoutExtension(Directory.EnumerateFiles(ProjectDirectory.TestRoot, "*.csproj").Single()); var publish = CreatePublishCommand(ProjectDirectory); - ExecuteCommand(publish, "-p:WriteImportMapToHtml=true", $"-p:FingerprintUserJavascriptAssets={fingerprintUserJavascriptAssets}").Should().Pass(); + ExecuteCommand(publish, "-p:OverrideHtmlAssetPlaceholders=true", $"-p:FingerprintUserJavascriptAssets={fingerprintUserJavascriptAssets.ToString().ToLower()}").Should().Pass(); var outputPath = publish.GetOutputDirectory(DefaultTfm, "Debug").ToString(); var indexHtmlOutputPath = Path.Combine(outputPath, "wwwroot", "index.html"); var endpointsManifestPath = Path.Combine(outputPath, $"{projectName}.staticwebassets.endpoints.json"); - AssertImportMapInHtml(indexHtmlOutputPath, endpointsManifestPath, scriptPath, expectFingerprintOnScript: expectFingerprintOnScript); + AssertImportMapInHtml(indexHtmlOutputPath, endpointsManifestPath, scriptPath, expectFingerprintOnScript: expectFingerprintOnScript, expectPreloadElement: testAsset == "VanillaWasm"); } private void FingerprintUserJavascriptAssets(bool fingerprintUserJavascriptAssets) @@ -125,7 +125,7 @@ private void ReplaceStringInIndexHtml(TestAsset testAsset, string sourceValue, s } } - private void AssertImportMapInHtml(string indexHtmlPath, string endpointsManifestPath, string scriptPath, bool expectFingerprintOnScript = true) + private void AssertImportMapInHtml(string indexHtmlPath, string endpointsManifestPath, string scriptPath, bool expectFingerprintOnScript = true, bool expectPreloadElement = false) { var indexHtmlContent = File.ReadAllText(indexHtmlPath); var endpoints = JsonSerializer.Deserialize(File.ReadAllText(endpointsManifestPath)); @@ -150,6 +150,12 @@ private void AssertImportMapInHtml(string indexHtmlPath, string endpointsManifes Assert.Contains(GetFingerprintedPath("_framework/dotnet.native.js"), indexHtmlContent); Assert.Contains(GetFingerprintedPath("_framework/dotnet.runtime.js"), indexHtmlContent); + if (expectPreloadElement) + { + Assert.DoesNotContain("", indexHtmlContent); + Assert.Contains($" endpoints.Endpoints.FirstOrDefault(e => e.Route == route && e.Selectors.Length == 0)?.AssetFile ?? throw new Exception($"Missing endpoint for file '{route}' in '{endpointsManifestPath}'"); } diff --git a/test/TestAssets/TestProjects/VanillaWasm/VanillaWasm.csproj b/test/TestAssets/TestProjects/VanillaWasm/VanillaWasm.csproj index 9676d0bfc24d..eb60f30a679a 100644 --- a/test/TestAssets/TestProjects/VanillaWasm/VanillaWasm.csproj +++ b/test/TestAssets/TestProjects/VanillaWasm/VanillaWasm.csproj @@ -2,5 +2,63 @@ $(CurrentTargetFramework) true + + _AddAppPreloadProperties; + $(GenerateHtmlAssetPlaceholdersBuildStaticWebAssetsDependsOn) + + + + <_AppendPreloadRelPreloadProperty Include="Append"> + Property + PreloadRel + preload + + <_AppendPreloadAsScriptProperty Include="Append"> + Property + PreloadAs + script + + <_AppendPreloadPriorityHighProperty Include="Append"> + Property + PreloadPriority + high + + <_AppendPreloadCrossoriginAnonymousProperty Include="Append"> + Property + PreloadCrossorigin + anonymous + + <_AppendPreloadGroupWebAssemblyProperty Include="Append"> + Property + PreloadGroup + webassembly + + + + + + <_AppPreloadScriptAsset Include="@(StaticWebAsset)" Condition="'%(FileName)%(Extension)' == 'main.js'" /> + <_AppPreloadEndpointFilter Include="Property" Name="Label" Mode="Include" Condition="'$(FingerprintUserJavascriptAssets)' == 'true'" /> + <_AppPreloadEndpointFilter Include="Property" Name="Label" Mode="Exclude" Condition="'@(_AppPreloadEndpointFilter)' == ''" /> + + + + + + + + + + + + diff --git a/test/TestAssets/TestProjects/VanillaWasm/wwwroot/index.html b/test/TestAssets/TestProjects/VanillaWasm/wwwroot/index.html index 8a30a27eb74e..3193039a8b68 100644 --- a/test/TestAssets/TestProjects/VanillaWasm/wwwroot/index.html +++ b/test/TestAssets/TestProjects/VanillaWasm/wwwroot/index.html @@ -7,6 +7,7 @@ WasmMySdk +