Skip to content
Matthew Adams By Matthew Adams Co-Founder · 5 min read
Building API Reference Documentation From Code, Part 1: The Pipeline

At endjin, we maintain Corvus.JsonSchema, an open source high-performance JSON library for .NET. We generate API reference documentation for 16 libraries across two engine versions (V4 and V5), spanning JSON Schema validation, query languages (JSONata, JMESPath, JsonLogic, JSONPath), mutable documents, YAML conversion, and JSON Patch. (We deliberately exclude the 7 V4 JSON Schema dialect libraries - Draft 4/6/7/201909/202012/OpenApi30/31 - because they contain thousands of generated types with repetitive patterns that would add ~25,000 pages and make the build impractically slow.)

That's a lot of public API surface to document. We needed API reference documentation that would stay accurate as the code evolved, support both engine versions side by side, and be enriched with hand-written examples and descriptions. It also needed to avoid requiring a human to manually update about 8,800 pages every time a method signature changed.

This post describes the system we built: a custom documentation pipeline that generates API reference directly from compiled assemblies and XML doc comments, produces a searchable static site with source links and TFM availability badges, and runs as part of our CI build.

The constraints

Before building anything, we listed what we needed:

  • Multi-assembly, multi-version: 8 libraries per engine version, with cross-assembly type links (e.g., a method in Corvus.Text.Json.Patch that takes a JsonElement defined in Corvus.Text.Json).
  • Version switcher: a V4 ↔ V5 toggle on the API landing pages, since both engines ship in the same toolchain and many users are evaluating which to adopt.
  • TFM availability: clear indication of which types and members exist on netstandard2.0 vs netstandard2.1 vs net10.0, since the library multi-targets and some APIs are only available on newer frameworks.
  • Regeneration-safe enrichment: hand-written namespace descriptions and type-level usage examples that survive when the generator runs, because useful documentation requires human context that can't be extracted from XML doc comments alone.
  • Source links: "View Source" links pointing at the exact file and line on GitHub, derived from PDB metadata rather than heuristics.
  • CI integration: the full documentation build runs in GitHub Actions alongside the library build and tests.

We evaluated existing tools. The combination of multi-assembly cross-linking, dual-version page generation, TFM badge scanning, and regeneration-safe enrichment we needed would have required substantial customisation of any off-the-shelf solution. Since we already had a static site generator (Vellum), we decided to build a focused tool that did exactly what we needed.

The core generator

The heart of the system is XmlDocToMarkdown, a C# console application that reads XML documentation files and compiled assemblies, and produces the artefacts needed to render API reference pages.

What goes in

For each library, the tool receives three inputs:

  1. XML documentation file (Corvus.Text.Json.xml) - the standard output from <GenerateDocumentationFile>true</GenerateDocumentationFile>, containing <summary>, <param>, <returns>, <remarks>, <example>, and <exception> elements for every documented member.

  2. Compiled assembly (Corvus.Text.Json.dll, net10.0 build) - inspected via System.Reflection to discover the actual public API surface: types, members, generic constraints, inheritance, interface implementations.

  3. Companion assemblies (netstandard2.0 and netstandard2.1 builds, optional) - scanned to determine which types and members are available on each target framework.

For multi-assembly documentation, these triplets are repeated. The V5 build step constructs the argument pairs for all 8 libraries:

$v5ToolArgs = @()
foreach ($proj in $v5Projects) {
    $binDir = Join-Path $v5SrcDir "$proj\bin\Release\net10.0"
    $xmlFile = Join-Path $binDir "$proj.xml"
    $dllFile = Join-Path $binDir "$proj.dll"
    $ns20Dll = Join-Path $v5SrcDir "$proj\bin\Release\netstandard2.0\$proj.dll"
    $ns21Dll = Join-Path $v5SrcDir "$proj\bin\Release\netstandard2.1\$proj.dll"
    if ((Test-Path $xmlFile) -and (Test-Path $dllFile)) {
        $v5ToolArgs += "--xml", $xmlFile, "--assembly", $dllFile
        if (Test-Path $ns20Dll) { $v5ToolArgs += "--ns20-assembly", $ns20Dll }
        if (Test-Path $ns21Dll) { $v5ToolArgs += "--ns21-assembly", $ns21Dll }
    }
}

What comes out

From those inputs, the tool generates several kinds of output:

Output Purpose V5 count
Namespace markdown One page per namespace with a type listing table 21
Type markdown One page per public type with signature, docs, member tables ~800
Member markdown One page per method overload group, property, operator, etc. ~2,500
Taxonomy YAML Metadata for Vellum (our static site generator) to route and render each page ~3,300
Razor views API index page with namespace cards, hierarchical sidebar partial 2
Search index JSON file consumed by Lunr for per-version type and member search 1

The output directory has a flat file structure, with naming conventions that encode the namespace and type hierarchy:

Api-v5/
├── corvus-numerics.md                              # Namespace page
├── corvus-numerics-bignumber.md                    # Type page
├── corvus-numerics-bignumber.parse.md              # Member page (method)
├── corvus-numerics-bignumber.op-addition.md        # Member page (operator)
├── corvus-text-json.md                             # Namespace page
├── corvus-text-json-jsonelement.md                 # Type page
├── corvus-text-json-jsonelement.clone.md           # Member page
├── corvus-text-json-jsonelement.createbuilder.md   # Member page
├── ...
├── namespaces/
│   ├── Corvus.Numerics.md              # Hand-authored namespace description
│   ├── Corvus.Text.Json.md             # (survives regeneration)
│   └── ...                             # 21 files
├── examples/
│   ├── corvus-text-json-jsonelement.md # Hand-authored type example
│   └── ...                             # 25 files
└── sidebar.html                        # Pre-rendered sidebar fragment

Each generated type page includes the full signature, XML doc summary, member tables with links, and (where available) source links and hand-authored examples. For example, the generated page for JsonElement.Clone looks like this:

## Definition
**Namespace:** Corvus.Text.Json
**Assembly:** Corvus.Text.Json.dll
**Source:** [JsonElement.cs](https://github.com/.../JsonElement.cs#L2288)

## Clone() {#clone}
Get a JsonElement which can be safely stored beyond the lifetime
of the original JsonDocument.

​```csharp
public JsonElement Clone()
​```

### Returns
[`JsonElement`](/api/v5/corvus-text-json-jsonelement.html)
A JsonElement which can be safely stored beyond the lifetime
of the original JsonDocument.

Each page also gets a companion taxonomy YAML file that tells the static site generator how to route and render it:

ContentType: application/vnd.endjin.ssg.page+yaml
Title: "JsonElement"
Template: api/v5/api-page
Navigation:
  Title: "JsonElement"
  Description: "Represents a specific JSON value within a JsonDocument."
  Parent: /api/v5
  Url: /api/v5/corvus-text-json-jsonelement.html
  Rank: 109
ContentBlocks:
  - ContentType: application/vnd.endjin.ssg.content+md
    Spec:
      Path: ../../content/Api-v5/corvus-text-json-jsonelement.md

In total, a single pipeline run produces about 8,800 API reference pages. That includes 3,300 for V5 and 5,500 for V4.

The complete pipeline

The documentation build script orchestrates everything in a single PowerShell pipeline:

Step What it does
0 Copy hand-authored source files (overviews, taxonomy seeds)
1a Build 8 V5 libraries (Release, net10.0 + netstandard2.0 + netstandard2.1)
1b Build 8 V4 libraries (Release, net10.0 + netstandard2.0)
2a Generate V5 API pages (markdown, taxonomy, views, search index)
2b Generate V4 API pages
3 Generate recipe content from 42 ExampleRecipes
4 Generate docs content from source documentation via descriptors
5 Install Vellum SSG
6 Run Vellum to render the core site
7 Compile SCSS and copy API search indices/sidebars
8 Build site-wide Lunr search index
9 Build and publish interactive playgrounds
10 Check for broken links (lychee)
11 Rewrite root-relative paths for GitHub Pages subpath hosting

The link checker (step 10) runs before step 11's path rewriting, so root-relative links like /api/v5/corvus-text-json-jsonelement.html resolve directly against the .output/ directory structure. Any broken internal link fails the build.

CI integration

The documentation build is integrated into our CI pipeline as a PostBuild task:

task PostBuild BuildWebSiteLocal

task BuildWebsite {
    $websiteDir = Join-Path $here "docs\website"

    $websiteBuildArgs = @{ SkipDotNetBuild = $true }

    if ($VellumDownloadToken) {
        $websiteBuildArgs += @{ VellumDownloadToken = (ConvertTo-SecureString $VellumDownloadToken -AsPlainText) }
    }

    $basePathPrefix = $env:BUILDVAR_BasePathPrefix
    if ($basePathPrefix) {
        $websiteBuildArgs += @{ BasePathPrefix = $basePathPrefix }
    }

    & (Join-Path $websiteDir "build.ps1") @websiteBuildArgs
}

task BuildWebSiteLocal -If { $BuildWebsite } BuildWebsite

The BuildWebSiteLocal wrapper means the website only builds when $BuildWebsite is set. You pass this flag explicitly for local documentation builds. In CI, the BuildWebsite task is invoked directly by the workflow. Either way, -SkipDotNetBuild means it reuses the already-compiled binaries from the main build step.

We also run a separate documentation code sample catalog check in our PreBuild task, which catches drift between documentation markdown and its code sample inventory before the build even starts.

Following one type through the system

To make this concrete, here's what happens when someone adds a new public method to JsonElement:

  1. The developer writes the method with XML doc comments (summary, params, returns, exceptions).
  2. CI builds the library - dotnet build produces the updated .dll and .xml.
  3. The XmlDocToMarkdown tool runs. It loads all 8 V5 assembly/XML pairs, then inspects each assembly.
  4. For JsonElement, it finds the new method in the assembly metadata, matches it to its XML doc entry, and checks whether the method exists in the netstandard2.0 and netstandard2.1 builds.
  5. It regenerates corvus-text-json-jsonelement.md (the type page) with the new method in its member table, and creates or updates corvus-text-json-jsonelement.{method-slug}.md (the member detail page) with the full signature, parameter docs, return type, exceptions, and source link.
  6. The hand-authored file examples/corvus-text-json-jsonelement.md is loaded and merged into the type page - untouched by the regeneration.
  7. The search index includes the new method. The sidebar includes it. The link checker validates all new links.

No one manually edited a documentation page. The developer wrote code and XML doc comments. Everything else was automated.

So that's the shape of the system: XML docs and compiled assemblies go in, a fully navigable documentation site comes out, and hand-authored enrichments survive regeneration. If you want to adapt the approach for your own project, the XmlDocToMarkdown source and the build pipeline are the places to start.

In Part 2, we'll go under the hood: how the cross-assembly linking, PDB-based source links, TFM scanning, enrichment merging, and search indexing actually work.

FAQs

Why build a custom tool instead of using DocFX or Sandcastle? We needed multi-assembly cross-linking across 8 libraries per version, dual-version documentation with a version switcher, TFM availability badges from netstandard2.0/2.1 assembly scanning, hand-authored enrichments that survive regeneration, and PDB-based source links. Achieving all of these with an existing tool would have required substantial customisation.
Can I adapt this approach for my own library? The core pattern is portable: read XML doc comments and assembly metadata via reflection, generate markdown or HTML per type and member, keep hand-authored enrichments in a separate directory that survives regeneration, and validate all links in CI. The specific tooling choices - Vellum for static site generation, Lunr for search, lychee for link checking - are substitutable.

Matthew Adams

Co-Founder

Matthew Adams

Matthew was CTO of a venture-backed technology start-up in the UK & US for 10 years, and is now the co-founder of endjin, which provides technology strategy, experience and development services to its customers who are seeking to take advantage of Microsoft Azure and the Cloud.