Connect to a Roslyn solution for live API snippets
By the end of this tutorial, your DocSite host loads a sibling C# class library through an inner .slnx and renders live Calculator and Greeter source inside a markdown page via csharp:xmldocid fences. You'll see how to register Pennington.Roslyn, set SolutionPath, write the three fence variants (:xmldocid, :xmldocid,bodyonly, and multi-symbol), and confirm that hot reload refreshes snippets when the backing source changes.
Prerequisites
- .NET 11 SDK installed
- Completed Scaffold a documentation site with DocSite (or have an equivalent DocSite host ready)
- A C# project or class library to fence into docs (we'll build a tiny one in unit 1)
The finished code for this tutorial lives in examples/BeyondRoslynExample.
1. Give your host a sibling library to fence
Before Pennington.Roslyn can pull source, there needs to be a .slnx listing the project that holds the types to embed. This unit stands up the dual-project shape the rest of the tutorial uses.
- 1
Review the starting DocSite host
This is the plain DocSite from the scaffold tutorial with no Roslyn wired yet. A
csharp:xmldocidfence dropped into a markdown page right now renders as a literal code block, because no preprocessor is registered.var builder = WebApplication.CreateBuilder(args); builder.Services.AddDocSite(() => new DocSiteOptions { SiteTitle = "Beyond Roslyn", Description = "Pulling live code snippets into docs with xmldocid fences.", GitHubUrl = "https://github.com/usepennington/pennington", HeaderContent = """<a href="/">Beyond Roslyn</a>""", FooterContent = """<footer class="mt-16 py-8 text-center text-sm text-base-500">Built with Pennington DocSite.</footer>""", }); var app = builder.Build(); app.UseDocSite(); await app.RunDocSiteAsync(args); - 2
Add a sibling
Sampleclass libraryDrop a
Sample/BeyondRoslynExample.Sample.csprojfolder next to the host csproj. SetGenerateDocumentationFile=trueso XmlDocId lookups resolve. Also setDefaultItemExcludeson the host csproj to skipSample\**— otherwise the two projects compete over the same.csfiles. - 3
Add two small types to fence
These are the symbols the rest of the tutorial points at. The XML doc comments on each member are what make them addressable by XmlDocId.
/// <summary> /// A tiny arithmetic helper used as the tutorial's xmldocid target. Nothing /// about this class is clever — the point is that the tutorial's doc prose /// can fence <c>M:BeyondRoslynExample.Sample.Calculator.Add(System.Int32,System.Int32)</c> /// and pull the real source into rendered HTML. /// </summary> public sealed class Calculator { /// <summary>Adds two integers.</summary> /// <param name="a">First addend.</param> /// <param name="b">Second addend.</param> /// <returns>The sum of <paramref name="a"/> and <paramref name="b"/>.</returns> public int Add(int a, int b) { return a + b; } /// <summary>Multiplies two integers.</summary> /// <param name="a">First factor.</param> /// <param name="b">Second factor.</param> /// <returns>The product of <paramref name="a"/> and <paramref name="b"/>.</returns> public int Multiply(int a, int b) { return a * b; } /// <summary>Returns the arithmetic mean of a non-empty sequence.</summary> /// <param name="values">Values to average. Must contain at least one element.</param> /// <exception cref="ArgumentException">Thrown if <paramref name="values"/> is empty.</exception> public double Mean(IReadOnlyList<int> values) { if (values.Count == 0) { throw new ArgumentException("At least one value is required.", nameof(values)); } var total = 0L; foreach (var v in values) { total += v; } return (double)total / values.Count; } }/// <summary> /// Builds friendly greetings. Exists so the tutorial's second xmldocid fence /// can reference a type other than <see cref="Calculator"/>. /// </summary> public sealed class Greeter { /// <summary>The greeting prefix, e.g. <c>"Hello"</c> or <c>"Bonjour"</c>.</summary> public string Prefix { get; } /// <summary>Creates a greeter with the supplied <paramref name="prefix"/>.</summary> public Greeter(string prefix) { Prefix = prefix; } /// <summary> /// Builds a greeting for <paramref name="name"/> using <see cref="Prefix"/>. /// </summary> /// <param name="name">The recipient's display name.</param> /// <returns>A string of the form "<c>{Prefix}, {name}!</c>".</returns> public string Greet(string name) { return $"{Prefix}, {name}!"; } } - 4
Write an inner
BeyondRoslynExample.slnxCreate an inner
.slnxthat registers only the Sample library.SolutionPathpoints at this file rather than the outer repo-level solution, so the MSBuild workspace loads exactly the source to fence into docs.<Solution> <Project Path="Sample/BeyondRoslynExample.Sample.csproj" /> </Solution>Note
On the .NET 11 preview SDK,
dotnet new slnemits an XML.slnxby default. If you prefer the legacy.slnformat, pass--format sln.SolutionPathaccepts either extension —Pennington.RoslynusesMicrosoft.CodeAnalysis.MSBuild.MSBuildWorkspace, which opens both.
Checkpoint — Two projects, one inner slnx
- Run
dotnet buildon both csprojs — they compile independently BeyondRoslynExample.slnxlives next to the host csproj and lists onlySample/BeyondRoslynExample.Sample.csproj- Run
dotnet runon the host — DocSite still serves, nothing has changed in the browser yet
2. Register Pennington.Roslyn and set SolutionPath
A single DI call turns on the xmldocid preprocessor. Once AddPenningtonRoslyn runs with SolutionPath set, every markdown page in the content folder gains the :xmldocid, :xmldocid,bodyonly, :xmldocid-diff, and :path fence modifiers.
Important
Pennington.Roslyn requires three package references, not one. Pennington.Roslyn itself, Microsoft.CodeAnalysis.Workspaces.MSBuild, and Microsoft.Build.Framework (with runtime excluded). Skipping either of the last two leaves the MSBuild workspace unable to launch its out-of-process BuildHost, and every csharp:xmldocid fence renders an error comment instead of source. The full csproj fragment is in the next step.
- 1
Add the three package references
Add all three to the host csproj.
Pennington.Roslynbrings inSyntaxHighlighterandRoslynCodeBlockPreprocessor; the other two are runtime requirements ofMicrosoft.CodeAnalysis.MSBuild.MSBuildWorkspace.<PackageReference Include="Pennington.Roslyn" Version="0.1.0-alpha.0.20" /> <PackageReference Include="Microsoft.CodeAnalysis.Workspaces.MSBuild" Version="5.3.0" /> <PackageReference Include="Microsoft.Build.Framework" Version="18.4.0" ExcludeAssets="runtime" PrivateAssets="all" />Microsoft.CodeAnalysis.Workspaces.MSBuildships theBuildHost-netcore/content DLLs the workspace launches at solution-load time. Without it, everycsharp:xmldocidfence renders an<!-- Error processing xmldocid: … BuildHost.dll not found -->comment. TheMicrosoft.Build.Frameworkreference (with runtime excluded) silences the MSBuild-locator resolution error without changing runtime behaviour. - 2
Call
AddPenningtonRoslynPoint it at the inner
.slnx. That's the whole wire-up — no middleware call, no extra endpoint.builder.Services.AddPenningtonRoslyn(opts => opts.SolutionPath = "path/to/your.slnx");SolutionPathis resolved withPath.GetFullPath, so a relative value is interpreted against the process working directory — that is, the folder you rundotnet runfrom, which is normally the host csproj folder. The example string"BeyondRoslynExample.slnx"works because the inner.slnxsits next to the csproj. To point at a sibling folder, use a relative path like"../OtherProject/Other.slnx"; an absolute path also works. - 3
See the options surface
RoslynOptionscarriesSolutionPath(required for fence resolution) andProjectFilter(narrows the workspace when the.slnxlists more than the docs need). For this tutorial, onlySolutionPathmatters. See Pennington.Roslyn.RoslynOptions for the full surface. - 4
See the registration-only state
Here is the stage 2 host: the same
AddDocSiteblock as stage 1 plus oneAddPenningtonRoslyncall. Nothing else changes.var builder = WebApplication.CreateBuilder(args); builder.Services.AddDocSite(() => new DocSiteOptions { SiteTitle = "Beyond Roslyn", Description = "Pulling live code snippets into docs with xmldocid fences.", GitHubUrl = "https://github.com/usepennington/pennington", HeaderContent = """<a href="/">Beyond Roslyn</a>""", FooterContent = """<footer class="mt-16 py-8 text-center text-sm text-base-500">Built with Pennington DocSite.</footer>""", }); builder.Services.AddPenningtonRoslyn(roslyn => { roslyn.SolutionPath = "BeyondRoslynExample.slnx"; }); var app = builder.Build(); app.UseDocSite(); await app.RunDocSiteAsync(args);
Checkpoint — The workspace loads at startup
- Run
dotnet runon the host - The first request takes a beat longer while
SolutionWorkspaceService(T:Pennington.Roslyn.Workspace.SolutionWorkspaceService) loads the inner slnx - No errors in the console — the workspace is hot and ready to resolve XmlDocIds
3. Write your first xmldocid fence
Now that RoslynCodeBlockPreprocessor (T:Pennington.Roslyn.Preprocessing.RoslynCodeBlockPreprocessor) is registered, any fenced code block whose info string ends in :xmldocid has its body parsed as one XmlDocId per line and resolved against the loaded workspace.
- 1
Create a new markdown page
Add
Content/api-pulls.mdwith a front-matter block (title,description,order) and a heading. The next step fences a type from the Sample library into it. - 2
Fence a whole type with
T:The fence language is
csharp:xmldocid. The body is a single XmlDocId —T:for a type,M:for a method,P:for a property,F:for a field./// <summary> /// A tiny arithmetic helper used as the tutorial's xmldocid target. Nothing /// about this class is clever — the point is that the tutorial's doc prose /// can fence <c>M:BeyondRoslynExample.Sample.Calculator.Add(System.Int32,System.Int32)</c> /// and pull the real source into rendered HTML. /// </summary> public sealed class Calculator { /// <summary>Adds two integers.</summary> /// <param name="a">First addend.</param> /// <param name="b">Second addend.</param> /// <returns>The sum of <paramref name="a"/> and <paramref name="b"/>.</returns> public int Add(int a, int b) { return a + b; } /// <summary>Multiplies two integers.</summary> /// <param name="a">First factor.</param> /// <param name="b">Second factor.</param> /// <returns>The product of <paramref name="a"/> and <paramref name="b"/>.</returns> public int Multiply(int a, int b) { return a * b; } /// <summary>Returns the arithmetic mean of a non-empty sequence.</summary> /// <param name="values">Values to average. Must contain at least one element.</param> /// <exception cref="ArgumentException">Thrown if <paramref name="values"/> is empty.</exception> public double Mean(IReadOnlyList<int> values) { if (values.Count == 0) { throw new ArgumentException("At least one value is required.", nameof(values)); } var total = 0L; foreach (var v in values) { total += v; } return (double)total / values.Count; } } - 3
Fence a single method with
M:Method XmlDocIds include full parameter types. The Sample library's
Addmethod takes twointparameters, so the XmlDocId readsM:...Add(System.Int32,System.Int32)./// <summary>Adds two integers.</summary> /// <param name="a">First addend.</param> /// <param name="b">Second addend.</param> /// <returns>The sum of <paramref name="a"/> and <paramref name="b"/>.</returns> public int Add(int a, int b) { return a + b; }
Checkpoint — Real source renders inside the docs
- Run
dotnet runand visithttp://localhost:5000/api-pulls - The
Calculatorclass and theAddmethod render as syntax-highlighted C#, pulled directly fromSample/Calculator.cs - Right-click → View Source: the markup is real
<pre><code>with TextMate-style token spans, not an image
4. Watch hot reload refresh the snippet
The workspace re-reads source on change. Edit the fenced method, request the page again, and Pennington serves the updated snippet without a manual rebuild.
- 1
Start the host in watch mode
Run
dotnet watchon the host csproj so file changes trigger a reload of the MSBuild workspace. Leave the browser open on/api-pulls. - 2
Edit the Sample library
Change the body of
AddinSample/Calculator.cs— add a comment or rename a local variable. Save the file.
Checkpoint — The page reflects the edit
- Refresh
/api-pulls - The
Addmethod snippet now shows the change, pulled fresh fromCalculator.cs - No manual docs rebuild was required — the workspace picked it up
5. Use the ,bodyonly variant and stack multiple symbols
Two fence options let you control what renders: append ,bodyonly to strip the declaration line, or list multiple XmlDocIds in one fence to concatenate their source.
- 1
Strip the declaration with
,bodyonlyAppending
,bodyonlyto the fence language returns only the block contents, or the expression-body expression for arrow members. Use it when the declaration is noise and the snippet should show what happens inside.return a * b; - 2
Concatenate multiple XmlDocIds
Place multiple XmlDocIds in one fence, one per line. The preprocessor renders them all in the order listed — useful for pairing two related members in the same code block.
/// <summary> /// Builds a greeting for <paramref name="name"/> using <see cref="Prefix"/>. /// </summary> /// <param name="name">The recipient's display name.</param> /// <returns>A string of the form "<c>{Prefix}, {name}!</c>".</returns> public string Greet(string name) { return $"{Prefix}, {name}!"; } /// <summary>Returns the arithmetic mean of a non-empty sequence.</summary> /// <param name="values">Values to average. Must contain at least one element.</param> /// <exception cref="ArgumentException">Thrown if <paramref name="values"/> is empty.</exception> public double Mean(IReadOnlyList<int> values) { if (values.Count == 0) { throw new ArgumentException("At least one value is required.", nameof(values)); } var total = 0L; foreach (var v in values) { total += v; } return (double)total / values.Count; }
Checkpoint — Both fence variants render
- Refresh
/api-pulls - The
Multiplyfence shows thereturn a * b;line only — nopublic int Multiply(...)declaration - The concatenated fence shows
GreetandMeanback-to-back in one highlighted code block
Summary
- A dual-project shape now stands up — a DocSite host plus a sibling Sample library wired through an inner slnx.
Pennington.Roslynis active via a singleAddPenningtonRoslyncall andRoslynOptions.SolutionPath.csharp:xmldocidfences cover types (T:), methods (M:), body-only snippets (,bodyonly), and multi-symbol blocks.- Hot reload refreshes rendered snippets when the backing source changes.