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/src/Pennington/Infrastructure/LinkVerificationService.cs b/src/Pennington/Infrastructure/LinkVerificationService.cs index f9c22d57..d8d4a317 100644 --- a/src/Pennington/Infrastructure/LinkVerificationService.cs +++ b/src/Pennington/Infrastructure/LinkVerificationService.cs @@ -155,10 +155,15 @@ private LinkCheckResult ClassifyLink(ContentRoute sourcePage, string url, LinkTy } } - // Framework-managed static asset paths — not content routes, skip verification - if (pathOnly.StartsWith("/_content/", StringComparison.OrdinalIgnoreCase) || - pathOnly.StartsWith("/_framework/", StringComparison.OrdinalIgnoreCase) || - pathOnly.StartsWith("/_blazor/", StringComparison.OrdinalIgnoreCase)) + // Framework-managed static asset paths — not content routes, skip verification. Accept + // both the root-absolute form (/_framework/blazor.web.js) and the base-relative form + // (_framework/blazor.web.js) a Blazor resolves — the standard blazor.web.js / + // blazor.webassembly.js script tags emit the latter, so a Blazor WebAssembly host would + // otherwise flag its own boot script on every page. + var frameworkPath = pathOnly.StartsWith('/') ? pathOnly : "/" + pathOnly; + if (frameworkPath.StartsWith("/_content/", StringComparison.OrdinalIgnoreCase) || + frameworkPath.StartsWith("/_framework/", StringComparison.OrdinalIgnoreCase) || + frameworkPath.StartsWith("/_blazor/", StringComparison.OrdinalIgnoreCase)) { return new ValidLink(sourcePage, url); } 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); + } +} diff --git a/tests/Pennington.Tests/Infrastructure/LinkVerificationServiceTests.cs b/tests/Pennington.Tests/Infrastructure/LinkVerificationServiceTests.cs index c83ed37d..a6084413 100644 --- a/tests/Pennington.Tests/Infrastructure/LinkVerificationServiceTests.cs +++ b/tests/Pennington.Tests/Infrastructure/LinkVerificationServiceTests.cs @@ -490,6 +490,23 @@ public void BaseUrl_UnprefixedFrameworkAssetStillValidInPassA() (results[0] is ValidLink).ShouldBeTrue(); } + [Fact] + public void RelativeFrameworkAsset_IsRecognizedAsValid() + { + // The standard Blazor boot script is 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]