Source content from outside the file system
To source content from somewhere MarkdownContentService<T> can't reach — a folder of JSON release notes, a SQL table, a remote API, generated API reference — and have those pages appear in navigation, cross-references, search, and the static build the same way markdown pages do, implement IContentService directly. The recipe below uses the ReleaseNotesContentService from examples/ExtensibilityLabExample, which turns Content/releases/*.json into /releases/{version}/ routes. For a second markdown tree with a different front-matter type, use chained AddMarkdownContent<T> instead — see Use multiple content sources.
Before you begin
- A working Pennington site on bare
AddPennington(see Create your first Pennington site if not) —AddDocSitepins its own markdown service, so adding a second content service on top works, but the concepts below assume familiarity with the unwrapped host. - Familiarity with the four-stage pipeline at a conceptual level (The content pipeline and union types).
- Source data that can be enumerated synchronously or asynchronously on startup —
DiscoverAsyncruns both at build time and on demand for live requests.
For a working setup, see examples/ExtensibilityLabExample — the ReleaseNotesContentService file is self-contained.
Model the source records
Define an immutable record that represents one page's worth of source data. ReleaseEntry is the JSON-backed shape the rest of the service keys off; the equivalent type in another project carries whatever fields the source provides.
/// <summary>One parsed release record read from a JSON source file.</summary>
public sealed record ReleaseEntry(
string Version,
string Title,
string Date,
IReadOnlyList<string> Highlights,
string SourcePath);
Implement the service
Create a sealed class implementing Pennington.Content.IContentService, inject whatever reads the source (here, IWebHostEnvironment for ContentRootPath), and cache the parsed records in a Lazy<ImmutableList<T>> so discovery and the TOC share one pass over the source.
Five members carry everything this how-to needs:
DiscoverAsyncyields oneDiscoveredItemper page. Build each item'sContentRoutewithContentRouteFactory.FromUrl(synthetic URL, no backing file) orContentRouteFactory.FromCustom(URL plus an on-diskFilePathso file-watching picks up edits), then pair the route with aContentSourcecase.EndpointSourceis used here so the build crawler fetches each URL through a siblingMapGetendpoint; the route is excluded fromsitemap.xmlbecause the canonical HTML is owned by the endpoint.GetContentTocEntriesAsyncreturns oneContentTocItemper row for the sidebar and the search index. SetTitle,Route,Order(tidy 10/20/30 sequences),HierarchyParts(sidebar nesting), andSectionLabel(group header).GetContentToCopyAsyncandGetContentToCreateAsynccover static assets (copied verbatim) and dynamically-generated sidecar files; both returnImmutableList.Emptywhen HTML served by an endpoint is the only output.LlmsTxtContentServiceuses the latter for stripped-markdown sidecars.GetCrossReferencesAsyncpublishes oneCrossReference(uid, title, route)per record so authors can deep-link specific entries with<xref:uid>. Pick a stable prefix (release-1.0.0here) so the uid does not depend on a URL that may move.
ContentSource is a union over MarkdownFileSource, RazorPageSource, RedirectSource, ProgrammaticSource, and EndpointSource — implicit conversions make the case-name shorthand work, so new EndpointSource() and new ContentSource(new EndpointSource()) are equivalent.
/// <summary>
/// Demonstrates <see cref="IContentService"/> by turning a folder of
/// <c>Content/releases/*.json</c> files into site pages, a navigation
/// section, and cross-reference entries.
/// <para>
/// Emits one <see cref="DiscoveredItem"/> per JSON file plus an index
/// route. The static-build crawler fetches each one over HTTP from the
/// running host, so a sibling <c>MapGet("/releases/{version}/")</c>
/// endpoint in <c>Program.cs</c> does the actual HTML rendering — the
/// same code path dev-mode uses. That keeps the service focused on
/// discovery, TOC, and cross-references and leaves presentation to the
/// endpoint.
/// </para>
/// <para>
/// Backs how-to 2.3.10 <c>/how-to/extensibility/custom-content-service</c>.
/// </para>
/// </summary>
public sealed class ReleaseNotesContentService : IContentService
{
private readonly string _releasesDirectory;
private readonly Lazy<ImmutableList<ReleaseEntry>> _entriesLazy;
public ReleaseNotesContentService(IWebHostEnvironment environment)
{
_releasesDirectory = Path.Combine(environment.ContentRootPath, "Content", "releases");
_entriesLazy = new Lazy<ImmutableList<ReleaseEntry>>(LoadEntries);
}
public string DefaultSectionLabel => "Releases";
public int SearchPriority => 20;
/// <summary>The full set of release entries this service knows about.</summary>
public IReadOnlyList<ReleaseEntry> Entries => _entriesLazy.Value;
/// <summary>Find a single release by version string, or null if no match.</summary>
public ReleaseEntry? TryGet(string version) =>
_entriesLazy.Value.FirstOrDefault(e => e.Version == version);
/// <summary>
/// One discovered item for the index plus one per JSON file. Each route is
/// paired with <see cref="EndpointSource"/> — the build crawler discovers
/// the URL and fetches it through the live pipeline, where the sibling
/// <c>MapGet</c> endpoint in <c>Program.cs</c> produces the HTML. These
/// items do not appear in <c>sitemap.xml</c>; that's the intended tradeoff
/// for routes whose canonical HTML lives behind a custom endpoint.
/// </summary>
public async IAsyncEnumerable<DiscoveredItem> DiscoverAsync()
{
yield return new DiscoveredItem(
ContentRouteFactory.FromUrl(new UrlPath("/releases/")),
new EndpointSource());
foreach (var entry in _entriesLazy.Value)
{
var route = ContentRouteFactory.FromCustom(
url: new UrlPath($"/releases/{entry.Version}/"),
sourceFile: new FilePath(entry.SourcePath));
yield return new DiscoveredItem(route, new EndpointSource());
}
await Task.CompletedTask;
}
/// <summary>No static files to copy — JSON sources are transformed, not republished.</summary>
public Task<ImmutableList<ContentToCopy>> GetContentToCopyAsync()
=> Task.FromResult(ImmutableList<ContentToCopy>.Empty);
/// <summary>
/// No dynamically generated files — each discovered route is served by a
/// <c>MapGet</c> endpoint whose HTTP response the crawler writes to disk.
/// Override this method when the output format is orthogonal to the site's
/// HTML pages (see <c>LlmsTxtContentService</c> for an example).
/// </summary>
public Task<ImmutableList<ContentToCreate>> GetContentToCreateAsync()
=> Task.FromResult(ImmutableList<ContentToCreate>.Empty);
/// <summary>TOC entries so the pages show up in navigation and the search index.</summary>
public Task<ImmutableList<ContentTocItem>> GetContentTocEntriesAsync()
{
var entries = _entriesLazy.Value;
var builder = ImmutableList.CreateBuilder<ContentTocItem>();
builder.Add(new ContentTocItem(
Title: "Releases",
Route: ContentRouteFactory.FromUrl(new UrlPath("/releases/")),
Order: 100,
HierarchyParts: ["releases"],
SectionLabel: DefaultSectionLabel,
Locale: null));
var order = 110;
foreach (var entry in entries)
{
builder.Add(new ContentTocItem(
Title: entry.Title,
Route: ContentRouteFactory.FromUrl(new UrlPath($"/releases/{entry.Version}/")),
Order: order,
HierarchyParts: ["releases", entry.Version],
SectionLabel: DefaultSectionLabel,
Locale: null));
order += 10;
}
return Task.FromResult(builder.ToImmutable());
}
/// <summary>One cross-reference per release so <c><xref:release-1.0.0></c> resolves.</summary>
public Task<ImmutableList<CrossReference>> GetCrossReferencesAsync()
{
var entries = _entriesLazy.Value;
var builder = ImmutableList.CreateBuilder<CrossReference>();
foreach (var entry in entries)
{
var route = ContentRouteFactory.FromUrl(new UrlPath($"/releases/{entry.Version}/"));
builder.Add(new CrossReference($"release-{entry.Version}", entry.Title, route));
}
return Task.FromResult(builder.ToImmutable());
}
private ImmutableList<ReleaseEntry> LoadEntries()
{
if (!Directory.Exists(_releasesDirectory))
return [];
var builder = ImmutableList.CreateBuilder<ReleaseEntry>();
foreach (var file in Directory.EnumerateFiles(_releasesDirectory, "*.json"))
{
var json = File.ReadAllText(file);
var dto = JsonSerializer.Deserialize<ReleaseJson>(json, JsonOptions);
if (dto is null) continue;
builder.Add(new ReleaseEntry(
Version: dto.Version,
Title: dto.Title,
Date: dto.Date,
Highlights: dto.Highlights ?? [],
SourcePath: file));
}
return [.. builder.OrderBy(e => e.Version, StringComparer.OrdinalIgnoreCase)];
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
private sealed record ReleaseJson(string Version, string Title, string Date, List<string>? Highlights);
}
For full member signatures (return types, default implementations, and the parent IContentEmitter interface), see Pennington.Content.IContentService.
Register the implementation
AddPennington does not auto-discover IContentService implementations — register directly on IServiceCollection. When an endpoint in Program.cs needs the concrete type to render detail pages, register it once by concrete type and forward IContentService to the same instance so the container does not create a second copy.
builder.Services.AddSingleton<ReleaseNotesContentService>();
builder.Services.AddSingleton<IContentService>(sp =>
sp.GetRequiredService<ReleaseNotesContentService>());
Result
The discovered records produce a "Releases" section in the sidebar, one route per entry, and one xref id per entry:
/releases/ -> Releases (index)
/releases/1.0.0/ -> uid: release-1.0.0
/releases/1.1.0/ -> uid: release-1.1.0
Each /releases/{version}/ URL renders through the sibling MapGet endpoint, the entries appear in the search index under the "Releases" section, and <xref:release-1.0.0> in any markdown page resolves to /releases/1.0.0/.
Verify
- Run
dotnet run --project examples/ExtensibilityLabExampleand visit/releases/— the index lists every entry and each/releases/{version}/renders. - The "Releases" section shows up in navigation with one child per discovered record.
- Authoring
<xref:release-1.0.0>inside a markdown page resolves to the right URL in the rendered output, and the static build (dotnet run -- build) writes one HTML file per route underoutput/releases/.
Related
- Reference: Content pipeline interfaces
- Reference: Routing types
- Background: The content pipeline and union types
- Background: When is DocSite the right starting point?