This site provides a machine-readable index at /llms.txt.

Skip to main content Skip to navigation

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) — AddDocSite pins 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 — DiscoverAsync runs 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:

  • DiscoverAsync yields one DiscoveredItem per page. Build each item's ContentRoute with ContentRouteFactory.FromUrl (synthetic URL, no backing file) or ContentRouteFactory.FromCustom (URL plus an on-disk FilePath so file-watching picks up edits), then pair the route with a ContentSource case. EndpointSource is used here so the build crawler fetches each URL through a sibling MapGet endpoint; the route is excluded from sitemap.xml because the canonical HTML is owned by the endpoint.
  • GetContentTocEntriesAsync returns one ContentTocItem per row for the sidebar and the search index. Set Title, Route, Order (tidy 10/20/30 sequences), HierarchyParts (sidebar nesting), and SectionLabel (group header).
  • GetContentToCopyAsync and GetContentToCreateAsync cover static assets (copied verbatim) and dynamically-generated sidecar files; both return ImmutableList.Empty when HTML served by an endpoint is the only output. LlmsTxtContentService uses the latter for stripped-markdown sidecars.
  • GetCrossReferencesAsync publishes one CrossReference(uid, title, route) per record so authors can deep-link specific entries with <xref:uid>. Pick a stable prefix (release-1.0.0 here) 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>&lt;xref:release-1.0.0&gt;</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/ExtensibilityLabExample and 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 under output/releases/.