Build a static site
To turn a working Pennington site running under dotnet run into a folder of static HTML for a static host, run the app in build mode. There is no separate build project — the same Program.cs that serves the site locally crawls itself over HTTP and writes the result to disk, so the locally tested site is exactly what ships.
For platform-specific upload steps, see Deploy to GitHub Pages. For sites hosted under a sub-path, see Host under a sub-path (base URL).
Assumptions
- A working Pennington site that serves under
dotnet run(see Create your first Pennington site if not) - The host composes
RunOrBuildAsync(directly, or viaRunDocSiteAsync/RunBlogSiteAsync) - A writable local directory — the build deletes and re-creates
output/by default
Steps
- 1
Confirm the host calls
RunOrBuildAsyncRunOrBuildAsyncis the single switch: no arguments means dev serve,buildas the first argument triggers the crawl-and-write path. Most apps already route through it viaRunDocSiteAsyncorRunBlogSiteAsync. The tail ofProgram.csconfirms it.using Pennington.DocSite; using SubPathDeployableExample; var builder = WebApplication.CreateBuilder(args); // Deliberately tiny DocSite host. The teaching surface for how-to §2.4 isn't // this code — it's the sibling deployment fixtures (`.github/workflows/deploy.yml`, // `staticwebapp.config.json`, `netlify.toml`, `nginx.conf`, `web.config`). // Keeping the host minimal makes `BaseUrlHtmlRewriter` behaviour observable // without noise when the site is built with a sub-path `baseUrl`. builder.Services.AddDocSite(ServiceConfiguration.BuildDocSiteOptions); var app = builder.Build(); app.UseDocSite(); // `RunDocSiteAsync` delegates to `RunOrBuildAsync`, so the same host serves // live (`dotnet run`) and writes static HTML (`dotnet run -- build [baseUrl]`). // The first positional arg after `build` is the base URL; pass `/my-sub-path` // to see BaseUrlHtmlRewriter prefix every anchor, asset, and script. await app.RunDocSiteAsync(args);For custom exit-code semantics — for example, failing CI on broken links but not on warnings — replace the call with the explicit switch written out longhand:
if (args.Length > 0 && args[0].Equals("build", StringComparison.OrdinalIgnoreCase)) { await app.StartAsync(); var generator = app.Services.GetRequiredService<OutputGenerationService>(); var report = await generator.GenerateAsync(); await app.StopAsync(); PrintBuildReport(report); } else { await app.RunAsync(); } - 2
Invoke the build verb
Pass
buildas the first argument todotnet run. The argument is parsed intoOutputOptionsviaFromArgs; without it, the app starts as a dev server instead. Three argument shapes are supported:# defaults: BaseUrl = "/", OutputDirectory = "output" dotnet run -- build # positional: base URL, then output dir dotnet run -- build /my-site dist # named flags (order-independent, preferred for scripts) dotnet run -- build --base-url=/my-site --output=distOutputOptions.FromArgsis the single source of truth for the CLI surface; see CLI and build arguments for the full grammar. - 3
Understand what the crawler does
OutputGenerationServicestarts the real ASP.NET host, opens anHttpClientagainst the first bound URL, and issues a GET for every route discovered byIContentService.DiscoverAsyncplus everyMapGetendpoint. Every page passes through the live response-processor pipeline — xref resolution, locale prefixing, base-URL rewriting, MonorailCSS class collection, and diagnostics behave identically in dev and build. This is a deliberate invariant — the reasoning is covered in Dev mode and build mode share one code path. - 4
Read the
BuildReportprinted to stdoutWhen the crawl finishes,
RunOrBuildAsyncwrites a human-readable report and exits with a non-zero code whenHasErrorsis true — triggered by any error diagnostic, failed page, or broken internal link. The key collections areGeneratedPages,SkippedPages(drafts),FailedPages,BrokenLinks, andDiagnostics; see Pennington.Generation.BuildReport for the full field list.For a custom CI presentation such as a GitHub Actions summary, print the report directly:
report.WriteTo(Console.Out); if (report.HasErrors) { Environment.ExitCode = 1; } - 5
Fix what the report flags before shipping
BrokenLinkssurfaces internal hrefs that did not resolve to a generated page — usually a typo or a moved file that no xref caught.FailedPagessurfaces routes whose parse or render raised an exception, each carrying the originatingContentRoute. Warnings are advisory and do not setHasErrorson their own, but a warning that represents a broken link flips the flag.
Verify
dotnet run -- buildexits0andoutput/index.htmlexists — open it in a browser and every internal link resolves- The stdout report ends with
Build Complete — N pages in Xs, with0 failedand0 broken links found output/404.htmlexists (the crawler fetches the internal/__pennington-404-generatorsentinel to materialize it)
Related
- Reference: CLI and build arguments
- Reference: Build report fields
- Background: Dev mode and build mode share one code path