From 3c17d23bfed06eb1a7c891bed8b17b02ea7b1112 Mon Sep 17 00:00:00 2001 From: Phil Scott Date: Tue, 7 Jul 2026 10:53:37 -0400 Subject: [PATCH 1/2] feat(generation): materialise Blazor WebAssembly boot manifest in static build The WASM boot manifest (_framework/resource-collection*.js) is a runtime endpoint synthesised by the WebAssembly render mode, not a physical static asset, so the static-asset copy phase misses it and a purely static host (e.g. GitHub Pages) 404s on WASM boot. Discover it from the endpoint data source and crawl it like any other GET so the fetch phase writes it to disk. --- .../Generation/OutputGenerationService.cs | 15 ++++ .../WebAssemblyBootAssetDiscovery.cs | 73 +++++++++++++++++++ .../WebAssemblyBootAssetDiscoveryTests.cs | 66 +++++++++++++++++ 3 files changed, 154 insertions(+) create mode 100644 src/Pennington/Generation/WebAssemblyBootAssetDiscovery.cs create mode 100644 tests/Pennington.Tests/Generation/WebAssemblyBootAssetDiscoveryTests.cs diff --git a/src/Pennington/Generation/OutputGenerationService.cs b/src/Pennington/Generation/OutputGenerationService.cs index 349588a3..d2b6f574 100644 --- a/src/Pennington/Generation/OutputGenerationService.cs +++ b/src/Pennington/Generation/OutputGenerationService.cs @@ -177,6 +177,21 @@ public async Task GenerateAsync(bool writeToDisk) mapGetPages.Add(page); } + // Phase 2b: The Blazor WebAssembly boot manifest (_framework/resource-collection*.js) is + // a runtime endpoint, not a physical static asset, so Phase 4's copy misses it and a static + // host 404s on WASM boot. Crawl it like any other GET so the fetch phase writes it. Deduped + // against claimed output files for symmetry, though its _framework path never collides. + foreach (var route in WebAssemblyBootAssetDiscovery.Discover(_endpointDataSource)) + { + if (!claimedOutputFiles.Add(route.OutputFile.Value)) + { + continue; + } + + mapGetPages.Add(new PageToGenerate(route)); + _logger.LogDebug("Discovered WebAssembly boot manifest: {Url}", route.CanonicalPath.Value); + } + _logger.LogInformation("Found {ContentCount} content pages, {EndpointCount} static endpoints", contentPages.Count, mapGetPages.Count); diff --git a/src/Pennington/Generation/WebAssemblyBootAssetDiscovery.cs b/src/Pennington/Generation/WebAssemblyBootAssetDiscovery.cs new file mode 100644 index 00000000..2f2ff239 --- /dev/null +++ b/src/Pennington/Generation/WebAssemblyBootAssetDiscovery.cs @@ -0,0 +1,73 @@ +namespace Pennington.Generation; + +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.StaticAssets; +using Routing; + +/// +/// Surfaces the Blazor WebAssembly boot manifest (_framework/resource-collection*.js) +/// so the static build materialises it. +/// +/// A Blazor Web App with an interactive-WebAssembly island boots via blazor.web.js, +/// which fetches a resource-collection ES module listing every WASM resource to load. +/// Unlike the assemblies/runtime — which are physical files under _framework/ that the +/// static-asset copy phase already emits — this manifest is generated at request time by an +/// endpoint the WebAssembly render mode registers (its fingerprint is computed from the loaded +/// resources at startup). It has no and no backing file, so +/// the copy phase misses it and a purely static host (e.g. GitHub Pages) 404s on WASM boot. +/// +/// The running host serves it during the crawl, so we treat it like any other GET route and let +/// the fetch phase write it to disk. Precompressed .js.gz variants are skipped: a dumb +/// static host serves the identity .js the boot script requests, uncompressed. +/// +internal static class WebAssemblyBootAssetDiscovery +{ + private const string ManifestPrefix = "_framework/resource-collection"; + + /// Yields a for each WebAssembly boot-manifest module on the data source. + public static IEnumerable Discover(EndpointDataSource endpointDataSource) + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var endpoint in endpointDataSource.Endpoints) + { + if (endpoint is not RouteEndpoint routeEndpoint) + { + continue; + } + + // Physical assets flow through the static-asset copy phase; only the synthesized + // manifest endpoint (no descriptor, no backing file) needs materializing here. + if (routeEndpoint.Metadata.GetMetadata() is not null) + { + continue; + } + + var rawText = routeEndpoint.RoutePattern.RawText; + if (string.IsNullOrWhiteSpace(rawText) || rawText.Contains('{')) + { + continue; + } + + var path = rawText.StartsWith('/') ? rawText[1..] : rawText; + + // The manifest module — both the fingerprinted route and its unfingerprinted alias — + // but not the .js.gz precompressed variants (a static host serves identity .js). + if (!path.StartsWith(ManifestPrefix, StringComparison.OrdinalIgnoreCase) || + !path.EndsWith(".js", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (!seen.Add(path)) + { + continue; + } + + yield return new ContentRoute + { + CanonicalPath = new UrlPath("/" + path), + OutputFile = new FilePath(path), + }; + } + } +} diff --git a/tests/Pennington.Tests/Generation/WebAssemblyBootAssetDiscoveryTests.cs b/tests/Pennington.Tests/Generation/WebAssemblyBootAssetDiscoveryTests.cs new file mode 100644 index 00000000..754a6cc3 --- /dev/null +++ b/tests/Pennington.Tests/Generation/WebAssemblyBootAssetDiscoveryTests.cs @@ -0,0 +1,66 @@ +namespace Pennington.Tests.Generation; + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.AspNetCore.Routing.Patterns; +using Microsoft.Extensions.Primitives; +using Pennington.Generation; +using Shouldly; + +public class WebAssemblyBootAssetDiscoveryTests +{ + [Fact] + public void Discovers_the_wasm_boot_manifest_module() + { + var source = new StubEndpointDataSource( + Endpoint("_framework/resource-collection.-9WPH.js")); + + var routes = WebAssemblyBootAssetDiscovery.Discover(source).ToList(); + + routes.Count.ShouldBe(1); + routes[0].CanonicalPath.Value.ShouldBe("/_framework/resource-collection.-9WPH.js"); + routes[0].OutputFile.Value.ShouldBe("_framework/resource-collection.-9WPH.js"); + } + + [Fact] + public void Includes_the_unfingerprinted_alias_and_dedupes_repeats() + { + var source = new StubEndpointDataSource( + Endpoint("_framework/resource-collection.-9WPH.js"), + Endpoint("_framework/resource-collection.-9WPH.js"), // content-negotiation duplicate + Endpoint("_framework/resource-collection.js")); + + var routes = WebAssemblyBootAssetDiscovery.Discover(source).Select(r => r.OutputFile.Value).ToList(); + + routes.ShouldBe( + ["_framework/resource-collection.-9WPH.js", "_framework/resource-collection.js"], + ignoreOrder: true); + } + + [Fact] + public void Skips_precompressed_variants_parameterized_and_unrelated_routes() + { + var source = new StubEndpointDataSource( + Endpoint("_framework/resource-collection.-9WPH.js.gz"), // precompressed + Endpoint("_framework/dotnet.js"), // physical framework file (copied elsewhere) + Endpoint("_framework/blazor.web.js"), // physical framework file + Endpoint("_framework/{**path}"), // parameterized + Endpoint("styles.css")); // not a framework route + + WebAssemblyBootAssetDiscovery.Discover(source).ShouldBeEmpty(); + } + + private static RouteEndpoint Endpoint(string pattern) => + new( + requestDelegate: _ => Task.CompletedTask, + routePattern: RoutePatternFactory.Parse(pattern), + order: 0, + metadata: EndpointMetadataCollection.Empty, + displayName: pattern); + + private sealed class StubEndpointDataSource(params RouteEndpoint[] endpoints) : EndpointDataSource + { + public override IReadOnlyList Endpoints { get; } = endpoints; + public override IChangeToken GetChangeToken() => new CancellationChangeToken(CancellationToken.None); + } +} From addd2513c21008ce2340b68557cf10b015b1b2be Mon Sep 17 00:00:00 2001 From: Phil Scott Date: Tue, 7 Jul 2026 10:53:46 -0400 Subject: [PATCH 2/2] fix(infrastructure): accept base-relative _framework paths in link verification The standard Blazor boot script emits a base-relative src (resolved by ): `"""; + + var results = service.VerifyLinks(source, html); + + results.Count.ShouldBe(1); + (results[0] is ValidLink).ShouldBeTrue(); + } + // --- Copied static assets are recognized as valid --- [Fact]