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

Skip to main content Skip to navigation

Create your first Pennington site

By the end of this tutorial a runnable ASP.NET project — MyFirstPenningtonSite — serves Content/index.md as HTML at http://localhost:5000/, with the front-matter title appearing in both the <title> tag and the page's <h1>.

The tutorial covers how to wire AddPennington, UsePennington, and RunOrBuildAsync into a minimal web host — the same foundation underneath every Pennington-powered site, whether DocSite, BlogSite, or hand-rolled. For when a bundled template is the faster path instead, Positioning DocSite as a fast path walks through the tradeoffs before you pick.

Prerequisites

Pennington targets .NET 11 with C# 15 union types. On .NET 10 the build reports language-version errors, so use the .NET 11 SDK before starting.

  • .NET 11 SDK installed (preview build — dotnet --version reports 11.0.*)
  • A terminal and a text editor or IDE that understands C# 15

The finished code for this tutorial lives in examples/GettingStartedMinimalSiteExample.


1. Scaffold a bare ASP.NET host

First, let's create the project shell Pennington will plug into — no Pennington code yet, a plain web app that returns a string, so the changes in step 2 stand out.

  1. 1

    Create the web project

    Run these two commands in a working folder. The web template produces a minimal top-level-statement Program.cs — no MVC, no Razor Pages — which is the starting shape we'll edit in the steps ahead.

    dotnet new web -n MyFirstPenningtonSite
    cd MyFirstPenningtonSite
  2. 2

    Add the Pennington package reference

    Add the Pennington package so the AddPennington extension method resolves. The backing example in this repo uses a ProjectReference, but for a new project this one command is enough.

    dotnet add package Pennington

    Important

    Pennington is in alpha — check NuGet for the current prerelease and pin every Pennington.* package to that same version.

  3. 3

    Opt into C# preview language features

    Pennington is built on C# 15 union types, and the samples in this tutorial use union pattern matching. The .NET 11 preview SDK does not enable preview language features by default, so compiling against Pennington without the opt-in produces error CS8652: The feature 'unions' is currently in Preview and *unsupported*. Edit the csproj to add <LangVersion>preview</LangVersion>:

    <Project Sdk="Microsoft.NET.Sdk.Web">
      <PropertyGroup>
        <TargetFramework>net11.0</TargetFramework>
        <LangVersion>preview</LangVersion>
        <Nullable>enable</Nullable>
        <ImplicitUsings>enable</ImplicitUsings>
      </PropertyGroup>
      <ItemGroup>
        <PackageReference Include="Pennington" Version="0.1.0-alpha.0.20" />
      </ItemGroup>
    </Project>

    For a multi-project host, dropping a Directory.Build.props at the solution root with the same <LangVersion>preview</LangVersion> property keeps every project aligned.

  4. 4

    Confirm the bare host runs

    Before adding Pennington, Program.cs looks like this — a plain WebApplication with a single MapGet that returns a string. This is the baseline we'll build on.

    var builder = WebApplication.CreateBuilder(args);
    var app = builder.Build();
      
    app.MapGet("/", () => "Hello from ASP.NET.");
      
    await app.RunAsync();

Checkpoint — Bare host responds

The browser shows the literal text Hello from ASP.NET. — no markdown involved at this stage. If HTML output appears instead, double-check that the running project is the bare scaffold from step 1.4, not a later stage.

  • Run dotnet run from the project folder.
  • Open http://localhost:5000/ and confirm the page body reads Hello from ASP.NET.
  • Stop the process with Ctrl+C before continuing.

2. Register Pennington and point it at markdown

Now let's swap the pass-through string endpoint for the Pennington content pipeline: AddPennington registers the core services, AddMarkdownContent<DocFrontMatter> names the markdown folder, and the host gains a ContentRootPath it will watch for changes.

  1. 1

    Create the Content folder and an index page

    Create a Content/ folder beside Program.cs, then add index.md with the contents below. Two things are required: a YAML front-matter block with a title: key, and a markdown body.

    ---
    title: Welcome to your first Pennington site
    description: The smallest Pennington host that renders a markdown page with front matter.
    ---
      
    # Hello from Pennington
      
    This page is a single markdown file in `Content/index.md`. Its `title` in the
    front matter above is what the host reads out when it renders the page.
      
    ## What just happened
      
    1. `AddPennington` registered the content pipeline.
    2. `AddMarkdownContent<DocFrontMatter>` pointed Pennington at this folder.
    3. `UsePennington` wired the middleware into the request pipeline.
    4. A tiny `MapGet` endpoint walks the content service, renders this file, and
       returns the HTML.
      
    Everything else you see in later tutorials builds on top of these four moves.
    
  2. 2

    Wire AddPennington in Program.cs

    Replace the body of Program.cs with the service-registration block below, which walks through WebApplication.CreateBuilderAddPenningtonAddMarkdownContent<DocFrontMatter>app.Build().

    var builder = WebApplication.CreateBuilder(args);
      
    builder.Services.AddPennington(penn =>
    {
        penn.SiteTitle = "My First Pennington Site";
        penn.ContentRootPath = "Content";
      
        penn.AddMarkdownContent<DocFrontMatter>(md =>
        {
            md.ContentPath = "Content";
            md.BasePageUrl = "/";
        });
    });
      
    var app = builder.Build();
      
    await app.RunAsync();

    ContentRootPath sets the host's base for static files; the ContentPath passed to AddMarkdownContent is where this particular markdown source reads from — both point at "Content" here.

Checkpoint — Services resolve

dotnet build succeeds with no errors. The host does not yet render the markdown page — stage 2 only registers services, so running it at this point returns a 404 for /. That's expected; hold off until step 3 adds the middleware.

  • Run dotnet build and confirm the build succeeds with no errors.
  • Do not run the site yet — the middleware arrives in the next step.

3. Wire the middleware and render the page

Now we mount the middleware chain with app.UsePennington(), add a MapGet that hands each request to the content pipeline, and hand control to RunOrBuildAsync — the same host that serves live today will generate static HTML tomorrow with no code change.

  1. 1

    Add UsePennington and RunOrBuildAsync

    Update Program.cs to match the snapshot below.

    var builder = WebApplication.CreateBuilder(args);
      
    builder.Services.AddPennington(penn =>
    {
        penn.SiteTitle = "My First Pennington Site";
        penn.ContentRootPath = "Content";
      
        penn.AddMarkdownContent<DocFrontMatter>(md =>
        {
            md.ContentPath = "Content";
            md.BasePageUrl = "/";
        });
    });
      
    var app = builder.Build();
      
    app.UsePennington();
      
    await app.RunOrBuildAsync(args);

    UsePennington installs static files, the response-processing middleware, live reload, and auto-registered endpoints like /sitemap.xml; RunOrBuildAsync serves live when called with no args and generates static HTML when passed -- build.

  2. 2

    Add the page-rendering endpoint

    The stage-3 snapshot registered services and middleware but didn't add a rendering endpoint. Here's the complete final Program.cs. It adds a MapGet that walks the IContentService set, finds the matching markdown, and returns rendered HTML.

    using Pennington.Content;
    using Pennington.FrontMatter;
    using Pennington.Infrastructure;
    using Pennington.Pipeline;
    using Pennington.Routing;
      
    var builder = WebApplication.CreateBuilder(args);
      
    // 1. Register the Pennington content pipeline. Point ContentRootPath at the
    //    folder of markdown files and declare one markdown source.
    builder.Services.AddPennington(penn =>
    {
        penn.SiteTitle = "My First Pennington Site";
        penn.ContentRootPath = "Content";
      
        penn.AddMarkdownContent<DocFrontMatter>(md =>
        {
            md.ContentPath = "Content";
            md.BasePageUrl = "/";
        });
    });
      
    var app = builder.Build();
      
    // 2. Wire the Pennington middleware (static files for Content/, live-reload,
    //    response processing, and auto-registered endpoints like /sitemap.xml).
    app.UsePennington();
      
    // 3. Serve any URL by walking the configured IContentService instances, parsing
    //    the matching markdown file, and rendering it through the pipeline. This
    //    is deliberately minimal: in later tutorials the DocSite template provides
    //    its own Razor layout and routing.
    app.MapGet("/{*path}", async (
        string? path,
        IEnumerable<IContentService> services,
        IContentParser parser,
        IContentRenderer renderer) =>
    {
        var requested = new UrlPath("/" + (path ?? string.Empty).Trim('/'));
      
        foreach (var service in services)
        {
            await foreach (var discovered in service.DiscoverAsync())
            {
                if (!discovered.Route.CanonicalPath.Matches(requested)) continue;
      
                var parsed = await parser.ParseAsync(discovered);
                if (parsed is not ParsedItem parsedItem) continue;
      
                var rendered = await renderer.RenderAsync(parsedItem);
                if (rendered is not RenderedItem renderedItem) continue;
      
                var html = $"""
                    <!DOCTYPE html>
                    <html lang="en">
                    <head>
                      <meta charset="utf-8" />
                      <title>{renderedItem.Metadata.Title}</title>
                    </head>
                    <body>
                      <article>
                        <h1>{renderedItem.Metadata.Title}</h1>
                        {renderedItem.Content.Html}
                      </article>
                    </body>
                    </html>
                    """;
                return Results.Content(html, "text/html");
            }
        }
      
        return Results.NotFound();
    });
      
    // 4. Dev mode (`dotnet run`) serves live; build mode
    //    (`dotnet run -- build <baseUrl> <outputDir>`) crawls the running app and
    //    writes static HTML. Both args are optional; defaults are `/` and `output`.
    await app.RunOrBuildAsync(args);

    This MapGet is deliberately minimal — in the DocSite and BlogSite tutorials the template ships its own Razor layout and routing, so this endpoint falls away once we move past the bare host.

Checkpoint — The page renders with its front-matter title

That's the working site. dotnet run serves live, and http://localhost:5000/ returns HTML whose <title> element and top-level <h1> both read Welcome to your first Pennington site, pulled straight from Content/index.md's front matter.

  • Run dotnet run from the project folder.
  • Open http://localhost:5000/ and confirm the page title in the browser tab reads Welcome to your first Pennington site.
  • View source and confirm the same string appears inside the <title> tag and the article's <h1>.

4. Verify dev-mode hot reload

Let's confirm that UsePennington's file-watcher and live-reload WebSocket are working: restart under dotnet watch, edit the markdown file, and watch the browser reload without touching the terminal.

  1. 1

    Run under dotnet watch

    Stop the previous dotnet run with Ctrl+C, then start the watcher. Live reload is gated on the DOTNET_WATCH environment variable, which dotnet watch sets automatically — no manual setup required. Leave http://localhost:5000/ open in the browser.

    dotnet watch
  2. 2

    Edit the front-matter title

    Open Content/index.md and change the title: value to something recognizable — for example title: Hello, Pennington — then save. The browser tab updates on its own within a second. If it doesn't, hard-refresh once; stale HTML may be cached from the earlier dotnet run.

Checkpoint — Live reload fires

Without any terminal input, the browser tab updates to show the new title in both the <h1> and the tab title. The dotnet watch console logs a file-change line naming Content/index.md.

  • Edit Content/index.md's title: field and save.
  • The browser tab title and page heading update to match — no manual refresh needed.
  • dotnet watch logs the change in the terminal.

Summary

  • An ASP.NET host now serves a markdown page end-to-end through AddPennington and UsePennington.
  • The content pipeline reads from a folder of markdown through ContentRootPath plus AddMarkdownContent<DocFrontMatter>.
  • RunOrBuildAsync means the same host generates a static site on dotnet run -- build with no code change.
  • A front-matter title: flows from YAML into the rendered <h1>, and dev-mode hot reload re-renders on save.