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.Patchthat takes aJsonElementdefined inCorvus.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.0vsnetstandard2.1vsnet10.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:
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.Compiled assembly (
Corvus.Text.Json.dll,net10.0build) - inspected viaSystem.Reflectionto discover the actual public API surface: types, members, generic constraints, inheritance, interface implementations.Companion assemblies (
netstandard2.0andnetstandard2.1builds, 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:
- The developer writes the method with XML doc comments (summary, params, returns, exceptions).
- CI builds the library -
dotnet buildproduces the updated.dlland.xml. - The
XmlDocToMarkdowntool runs. It loads all 8 V5 assembly/XML pairs, then inspects each assembly. - 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 thenetstandard2.0andnetstandard2.1builds. - It regenerates
corvus-text-json-jsonelement.md(the type page) with the new method in its member table, and creates or updatescorvus-text-json-jsonelement.{method-slug}.md(the member detail page) with the full signature, parameter docs, return type, exceptions, and source link. - The hand-authored file
examples/corvus-text-json-jsonelement.mdis loaded and merged into the type page - untouched by the regeneration. - 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.