This documentation is also published as Markdown for efficient machine reading: the whole site is indexed at /llms.txt, and every page has a clean Markdown copy at the same URL with .md appended. These are generated from the same source and cost far fewer tokens to read than this rendered HTML.

Skip to main content Skip to navigation
Guides

Version a DocSite

Ship /v1/ and /v2/ URL trees from one DocSite host, each with its own content area and its own reflection-based API reference.

To serve /v1/ and /v2/ URL trees from one DocSite host, give each version its own ContentArea. One area per version is the whole mechanism for prose-only docs — the Lay out content by version section below is all you need.

The rest of this page layers a per-version API reference on top, which is where the only real friction lives: NuGet allows one version of an assembly per project, so the off-version DLL needs a <PackageDownload> workaround. If you don't need a reflected API tree, stop after the areas section.

The recipe references examples/VersionedDocSiteExample/, which documents Humanizer.Core 2.8.26 alongside 2.14.1. For how AddApiMetadataFromCompiledAssembly and AddApiReference work on a single version, see Auto-generate an API reference tree for a class library.

Before you begin

  • A DocSite host already wired with AddDocSite (see Scaffold a documentation site with DocSite).
  • A decision about which version is the active PackageReference. That version resolves via FromPackageReference("AssemblyName"). Every other version is staged via <PackageDownload> and an explicit AssemblyFiles path.

Lay out content by version

Use one ContentArea per version. The Slug is both the URL prefix and the folder name under Content/, so files at Content/v1/foo.md route to /v1/foo and the sidebar renders an area selector that doubles as a version switcher.

csharp
public static void AddVersionedAreas(WebApplicationBuilder builder)
{
    builder.Services.AddDocSite(() => new DocSiteOptions
    {
        SiteTitle = "Humanizer Docs",
        SiteDescription = "Side-by-side documentation for two versions of Humanizer.Core, with version-scoped content and a sidebar version selector.",
        GitHubUrl = "https://github.com/Humanizr/Humanizer",
        HeaderContent = """<a href="/" class="font-bold text-lg">Humanizer Docs</a>""",
        FooterContent = """<footer class="mt-16 py-8 text-center text-sm text-base-500">Humanizer © Mehdi Khalili & contributors. Rendered by Pennington.</footer>""",
        Areas =
        [
            new ContentArea("v1", "v1"),
            new ContentArea("v2", "v2"),
        ],
    });
}

The Areas declaration is the only place the version names appear in the host wiring. Adding a v3 later is two lines plus a Content/v3/ folder.

A bare / request — anyone landing on the site root with no version prefix — falls through to the DocSite not-found page unless you give the root a page; add a Content/index.md (or a routed landing component) that redirects to or links the version you treat as current. Marking one version "latest" and showing a deprecation banner on older trees are content-level conventions, not host wiring: drop a shared [!INCLUDE] partial into each old version's pages for the banner, and point the root and header link at the current slug. See Forward visitors from a renamed page for the root-redirect mechanics.

Reference two versions of the same NuGet package

NuGet allows only one <PackageReference> per assembly per project. To document a second version, add a <PackageDownload> element pinned with square-bracket exact-version syntax. <PackageDownload> fetches the package into the NuGet cache without adding it to the compile graph, leaving the <PackageReference> version as the one resolved through the default load context.

xml
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
  <ItemGroup>
    <ProjectReference Include="..\..\src\Pennington.DocSite\Pennington.DocSite.csproj" />
    <ProjectReference Include="..\..\src\Pennington.DocSite.Api\Pennington.DocSite.Api.csproj" />
    <ProjectReference Include="..\..\src\Pennington.ApiMetadata.Reflection\Pennington.ApiMetadata.Reflection.csproj" />
  </ItemGroup>
  <ItemGroup>
    <!-- v2 is the active PackageReference. FromPackageReference("Humanizer")
         resolves this through the default load context. -->
    <PackageReference Include="Humanizer.Core" />
    <!-- v1 is staged into the NuGet cache without being compiled against.
         PackageDownload is how NuGet expresses "fetch this version, but do not
         add it to the compile graph" — necessary because a single project
         cannot PackageReference two versions of the same assembly. The exact
         version pin (square brackets) is required by PackageDownload. -->
    <PackageDownload Include="Humanizer.Core" Version="[2.8.26]" />
  </ItemGroup>
  <ItemGroup>
    <Watch Include="Content\**\*.*" />
  </ItemGroup>
</Project>

In Program.cs, register one named provider per version, then pair each with an AddApiReference registration whose RoutePrefix nests under the matching area slug:

csharp
public static void AddVersionedApiReferences(WebApplicationBuilder builder)
{
    var nuGetPackages = Environment.GetEnvironmentVariable("NUGET_PACKAGES")
        ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".nuget", "packages");
    var humanizerV1Dll = Path.Combine(nuGetPackages, "humanizer.core", "2.8.26", "lib", "netstandard2.0", "Humanizer.dll");
  
    builder.Services.AddApiMetadataFromCompiledAssembly("humanizer-v1", opts =>
        opts.AssemblyFiles.Add(humanizerV1Dll));
    builder.Services.AddApiMetadataFromCompiledAssembly("humanizer-v2", opts =>
        opts.FromPackageReference("Humanizer"));
  
    builder.Services.AddApiReference("humanizer-v1", opts =>
    {
        opts.RoutePrefix = "/v1/api/";
        opts.TocTitle = "API reference";
    });
    builder.Services.AddApiReference("humanizer-v2", opts =>
    {
        opts.RoutePrefix = "/v2/api/";
        opts.TocTitle = "API reference";
    });
}
  • The active reference uses FromPackageReference("Humanizer")Assembly.Load finds the v2 DLL via the project's deps.json.
  • The off-version uses AssemblyFiles.Add(path) with an explicit path under the NuGet global-packages folder. Read that folder from the NUGET_PACKAGES environment variable and fall back to the per-user default (~/.nuget/packages on Linux and macOS, %USERPROFILE%\.nuget\packages on Windows) — Environment.GetFolderPath(SpecialFolder.UserProfile) resolves the home directory on every platform. Inside it, the simple-name folder is lowercased; the version is the literal <PackageDownload> value; the TFM is whichever lib/<tfm>/ the package ships.

The two registrations resolve as follows:

Provider name RoutePrefix Resolves
humanizer-v1 /v1/api/ Humanizer.dll 2.8.26 from the NuGet cache
humanizer-v2 /v2/api/ Humanizer.dll 2.14.1 via the active PackageReference

The Mdazor components (<ApiMemberTable>, <ApiSummary>, …) are registered once and resolve metadata per page via the keyed provider, so two trees coexist with no further wiring.

Each named registration emits xref uids under reference.api.{name}.{slug} — for example, <xref:reference.api.humanizer-v1.string-humanize-extensions> and <xref:reference.api.humanizer-v2.string-humanize-extensions>. Use the qualified form when a v2 content page links to a v1 type to show what changed, or vice versa.

Verify

  • Run dotnet run --project examples/VersionedDocSiteExample.
  • Visit /v1/, /v2/, /v1/api/, and /v2/api/ — each renders independently.
  • The startup log prints one ApiReferenceIndex({name}): published N auto-discovered type pages line per registration. N differs between versions when the two assemblies differ.
  • Confirm the sidebar area selector switches between v1 and v2 while staying on the same page kind.