Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/Pennington/Generation/OutputGenerationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,21 @@ public async Task<BuildReport> 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);

Expand Down
73 changes: 73 additions & 0 deletions src/Pennington/Generation/WebAssemblyBootAssetDiscovery.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
namespace Pennington.Generation;

using Microsoft.AspNetCore.Routing;
using Microsoft.AspNetCore.StaticAssets;
using Routing;

/// <summary>
/// Surfaces the Blazor WebAssembly boot manifest (<c>_framework/resource-collection*.js</c>)
/// so the static build materialises it.
///
/// A Blazor Web App with an interactive-WebAssembly island boots via <c>blazor.web.js</c>,
/// which fetches a <c>resource-collection</c> ES module listing every WASM resource to load.
/// Unlike the assemblies/runtime — which are physical files under <c>_framework/</c> 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 <see cref="StaticAssetDescriptor"/> 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 <c>.js.gz</c> variants are skipped: a dumb
/// static host serves the identity <c>.js</c> the boot script requests, uncompressed.
/// </summary>
internal static class WebAssemblyBootAssetDiscovery
{
private const string ManifestPrefix = "_framework/resource-collection";

/// <summary>Yields a <see cref="ContentRoute"/> for each WebAssembly boot-manifest module on the data source.</summary>
public static IEnumerable<ContentRoute> Discover(EndpointDataSource endpointDataSource)
{
var seen = new HashSet<string>(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<StaticAssetDescriptor>() 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),
};
}
}
}
13 changes: 9 additions & 4 deletions src/Pennington/Infrastructure/LinkVerificationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <base href> 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);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Endpoint> Endpoints { get; } = endpoints;
public override IChangeToken GetChangeToken() => new CancellationChangeToken(CancellationToken.None);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 <base href>):
// `<script src="_framework/blazor.web.js">`. Without a leading slash it must still be
// treated as a framework asset, or a Blazor WebAssembly host flags its own boot script
// as a broken link on every page.
var service = new LinkVerificationService([], baseUrl: "/");
var source = MakeRoute("/playground");
var html = """<script src="_framework/blazor.web.js"></script>""";

var results = service.VerifyLinks(source, html);

results.Count.ShouldBe(1);
(results[0] is ValidLink).ShouldBeTrue();
}

// --- Copied static assets are recognized as valid ---

[Fact]
Expand Down
Loading