Scaffold a documentation site with DocSite
By the end of this tutorial the DocSite host runs with a "Scaffold Docs" title, GitHub icon, header/footer chrome, and two content areas — Guides and Reference — each serving an index page from its own top-level folder.
This tutorial covers swapping a plain Pennington host for the DocSite template, populating DocSiteOptions, and understanding how area slugs bind top-level folders to URL prefixes and sidebar tabs. For the shape the template hard-codes — and the seams it leaves open — read Positioning DocSite as a fast path first.
Prerequisites
- .NET 11 SDK installed
- Completed Create your first Pennington site — in particular the
<LangVersion>preview</LangVersion>opt-in from step 1.3, which Pennington requires across every project that references it - Completed Add your first markdown page (so
Content/already has at least one page)
Important
If dotnet build here fails with error CS8652: The feature 'unions' is currently in Preview, the host csproj is missing <LangVersion>preview</LangVersion>. See step 1.3 of Create your first Pennington site for the property and the multi-project Directory.Build.props form.
The finished code for this tutorial lives in examples/DocSiteScaffoldExample.
1. Start from the bare Pennington host
The starting host wires AddPennington, UsePennington, and a hand-written MapGet fallback that walks IContentService to render pages. The DocSite template replaces all of that.
- 1
Review the pre-DocSite host shape
The starting state has three moving parts: DI registration, middleware, and the fallback endpoint.
var builder = WebApplication.CreateBuilder(args); builder.Services.AddPennington(penn => { penn.SiteTitle = "Scaffold Docs"; penn.ContentRootPath = "Content"; penn.AddMarkdownContent<DocFrontMatter>(md => { md.ContentPath = "Content"; md.BasePageUrl = "/"; }); }); var app = builder.Build(); app.UsePennington(); 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; return Results.Content(renderedItem.Content.Html, "text/html"); } } return Results.NotFound(); }); await app.RunOrBuildAsync(args);Everything the DocSite template adds — sidebar, header chrome, MonorailCSS, SPA navigation, the Razor component layout — is absent here. The next step collapses those ~30 lines into a single DI call.
Checkpoint — The bare host runs
- Run
dotnet runand visithttp://localhost:5000/ - The markdown renders as unstyled HTML — no sidebar, no header, no theme
2. Swap AddPennington for AddDocSite
AddDocSite is a single DI call that registers Pennington core, MonorailCSS, SPA navigation, the ContentResolver, and the DocSiteArticleSlotRenderer Razor island — all driven from one options object.
- 1
Replace the registration call
AddDocSitetakes aFunc<DocSiteOptions>rather than anAction, so the call constructs and returns a fresh options record. TheAddMarkdownContentcall can also go — the template registers it internally. See DI and middleware extension methods for the full signature. - 2
Populate
DocSiteOptionsThis tutorial uses five fields:
SiteTitle,Description,GitHubUrl,HeaderContent, andFooterContent. Each one surfaces in the rendered chrome as soon as it's set.DocSiteOptionscarries many more fields; see Pennington.DocSite.DocSiteOptions for the full surface, and Positioning DocSite as a fast path for what the template hard-codes. - 3
See the registration-only state
At this point
AddDocSiteis wired butUseDocSitehasn't been called yet. The host builds, but the middleware stack is still the ASP.NET default. Theawait app.RunAsync()call is a placeholder that the next section replaces.var builder = WebApplication.CreateBuilder(args); builder.Services.AddDocSite(() => new DocSiteOptions { SiteTitle = "Scaffold Docs", Description = "A minimal DocSite scaffold showing AddDocSite and area routing.", GitHubUrl = "https://github.com/usepennington/pennington", HeaderContent = """<a href="/">Scaffold Docs</a>""", FooterContent = """<footer class="mt-16 py-8 text-center text-sm text-base-500">Built with Pennington DocSite.</footer>""", Areas = [ new ContentArea("Guides", "guides"), new ContentArea("Reference", "reference"), ], }); var app = builder.Build(); await app.RunAsync();
Checkpoint — Services registered, middleware not yet mounted
dotnet buildsucceedsdotnet runstarts the host, but/returns a default ASP.NET response — the DocSite middleware is registered in DI but not mounted in the pipeline
3. Mount the DocSite middleware
UseDocSite is the middleware counterpart to AddDocSite — one call mounts locale routing, antiforgery, static files, Razor component routing, MonorailCSS, SPA navigation, and core Pennington middleware in the correct order.
- 1
Call
UseDocSiteafterBuild()This single call replaces both the old
UsePenningtonline and the hand-writtenMapGetfallback from stage 1. The RazorPages.razorcomponent owns the/{*fileName:nonfile}route and resolves pages throughContentResolver.app.UseDocSite(); - 2
Swap
RunAsyncforRunDocSiteAsyncRunDocSiteAsyncdelegates toRunOrBuildAsync, so the same host serves pages live in development and generates static HTML when invoked asdotnet run -- build <baseUrl> <outputDir>— one code path for both modes.await app.RunDocSiteAsync(args); - 3
See the fully-wired host
The canonical final shape has three calls that match
Program.csverbatim:AddDocSite,UseDocSite,RunDocSiteAsync.var builder = WebApplication.CreateBuilder(args); builder.Services.AddDocSite(() => new DocSiteOptions { SiteTitle = "Scaffold Docs", Description = "A minimal DocSite scaffold showing AddDocSite and area routing.", GitHubUrl = "https://github.com/usepennington/pennington", HeaderContent = """<a href="/">Scaffold Docs</a>""", FooterContent = """<footer class="mt-16 py-8 text-center text-sm text-base-500">Built with Pennington DocSite.</footer>""", Areas = [ new ContentArea("Guides", "guides"), new ContentArea("Reference", "reference"), ], }); var app = builder.Build(); app.UseDocSite(); await app.RunDocSiteAsync(args);
Checkpoint — Full chrome renders
- Run
dotnet runand visithttp://localhost:5000/ - The DocSite layout renders: left sidebar, header with site title, search affordance, dark-mode toggle, GitHub icon linking to
GitHubUrl, and the footer HTML fromFooterContent
4. Map content to areas
DocSiteOptions.Areas is a list of ContentArea(Label, Slug) pairs. Each slug binds a top-level folder under ContentRootPath to a URL prefix and to its own sidebar tab.
- 1
Review the
ContentAreacontractContentAreahas two fields: a human-readable label that appears in the area selector, and a slug that matches the folder name and URL prefix. The order of entries inAreasdrives the order of tabs in the sidebar.public record ContentArea(string Label, string Slug); - 2
Create the area folders
Under
Content/, create two folders —guides/andreference/— each with anindex.md. Theguidesslug inDocSiteOptions.AreasbindsContent/guides/to the/guides/URL prefix and to the Guides sidebar tab. Thereferenceslug works the same way.--- title: Guides description: The Guides area introduction. sectionLabel: Guides order: 10 --- # Guides Welcome to the **Guides** area. Every markdown file that lives under `Content/guides/` becomes a page in this area — the `guides` slug in `DocSiteOptions.Areas` binds the folder to a URL prefix and to the sidebar's Guides tab. Start here for tasks, walkthroughs, and runbooks.--- title: Reference description: The Reference area introduction. sectionLabel: Reference order: 10 --- # Reference Welcome to the **Reference** area. Reference pages live under `Content/reference/` and share nothing with Guides — the `reference` slug in `DocSiteOptions.Areas` puts them on their own sidebar tab and under their own top-level URL prefix. Come here when you need authoritative, look-up-style material. - 3
Confirm the two-area
AreaslistThe
Areasblock in the stage 3 host has exactly twoContentAreaentries. The sidebar only shows the area selector when more than one area is configured, so with both entries in place the tab switcher appears for the first time.
Checkpoint — Both areas resolve and switch independently
- Visit
http://localhost:5000/guides/— the Guides index page renders with the Guides tab selected in the sidebar - Visit
http://localhost:5000/reference/— the Reference index page renders, the Reference tab is now selected, and the sidebar TOC filters to the Reference area only
5. Give the root / a landing page
With Areas configured, the URL / sits outside every area — it is not a default redirect into the first area, and the area selector shows no active tab there. To make / render something other than a 404, drop a markdown file at Content/index.md (next to the area folders, not inside them).
- 1
Author
Content/index.mdUse the same
DocSiteFrontMattershape as any other page. The page resolves through the same content pipeline as area pages — the only thing that makes it the root is its location atContent/index.md.--- title: Welcome to Scaffold Docs description: Pick an area to get started. --- # Welcome - [Guides](/guides/) — task walk-throughs and onboarding. - [Reference](/reference/) — every option, key, and surface. - 2
Verify the root renders without an active area
Visit
http://localhost:5000/— the page renders inside the DocSite chrome, the area selector shows no active tab (because the root is outside every area), and the sidebar is empty for the same reason. Any/guides/...or/reference/...link inside the page navigates into the matching area and lights up the corresponding sidebar tab.
Checkpoint — / is a real page, not a 404
http://localhost:5000/returns the renderedContent/index.mdpage with the DocSite chrome around it- The area selector shows neither Guides nor Reference as active until the reader clicks into one
- A request to
/some-area/still resolves the matching area as in unit 4
Summary
- The bare
AddPenningtonhost was replaced withAddDocSite+UseDocSite+RunDocSiteAsync, and the full Razor chrome renders. DocSiteOptionscarriesSiteTitle,Description,GitHubUrl,HeaderContent, andFooterContent, and each field appears in the rendered layout.- Two
ContentAreaentries bind top-level folders underContent/to URL prefixes and to sidebar tabs. - The root
/is served byContent/index.md, which sits outside every area — without it,/returns a 404 even when areas are configured. - DocSite is a fast-path template — for the knobs it hard-codes, see Positioning DocSite as a fast path.