This site provides a machine-readable index at /llms.txt.

Skip to main content Skip to navigation

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 via RunDocSiteAsync / RunBlogSiteAsync)
  • A writable local directory — the build deletes and re-creates output/ by default

Steps

  1. 1

    Confirm the host calls RunOrBuildAsync

    RunOrBuildAsync is the single switch: no arguments means dev serve, build as the first argument triggers the crawl-and-write path. Most apps already route through it via RunDocSiteAsync or RunBlogSiteAsync. The tail of Program.cs confirms 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. 2

    Invoke the build verb

    Pass build as the first argument to dotnet run. The argument is parsed into OutputOptions via FromArgs; 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=dist

    OutputOptions.FromArgs is the single source of truth for the CLI surface; see CLI and build arguments for the full grammar.

  3. 3

    Understand what the crawler does

    OutputGenerationService starts the real ASP.NET host, opens an HttpClient against the first bound URL, and issues a GET for every route discovered by IContentService.DiscoverAsync plus every MapGet endpoint. 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. 4

    Read the BuildReport printed to stdout

    When the crawl finishes, RunOrBuildAsync writes a human-readable report and exits with a non-zero code when HasErrors is true — triggered by any error diagnostic, failed page, or broken internal link. The key collections are GeneratedPages, SkippedPages (drafts), FailedPages, BrokenLinks, and Diagnostics; 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. 5

    Fix what the report flags before shipping

    BrokenLinks surfaces internal hrefs that did not resolve to a generated page — usually a typo or a moved file that no xref caught. FailedPages surfaces routes whose parse or render raised an exception, each carrying the originating ContentRoute. Warnings are advisory and do not set HasErrors on their own, but a warning that represents a broken link flips the flag.


Verify

  • dotnet run -- build exits 0 and output/index.html exists — open it in a browser and every internal link resolves
  • The stdout report ends with Build Complete — N pages in Xs, with 0 failed and 0 broken links found
  • output/404.html exists (the crawler fetches the internal /__pennington-404-generator sentinel to materialize it)