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

Skip to main content Skip to navigation

Emit generated output artifacts

To emit a byte artifact into the output — robots.txt, a sitemap variant, a social-image .png, a sidecar .json search index — that is not a routed page, not in navigation, and not an xref target, implement IContentService with GetContentToCreateAsync as the only meaningful member. Every other interface member returns empty. For the opposite shape — a service that contributes routed pages, TOC entries, and xrefs from a non-markdown source — see Source content from outside the file system.

Before you begin

For a working setup, see examples/ExtensibilityLabExampleRobotsTxtContentService.cs is self-contained and Program.cs registers it against a bare AddPennington host. LlmsTxtContentService in the core library is the production example of the same pattern.

Implement the service

Create a sealed class implementing Pennington.Content.IContentService. The full example is one type; the sections below walk through the one member that does work and the empty-return shape of the rest.

/// <summary>
/// Demonstrates the "emit artifacts only" <see cref="IContentService"/> shape —
/// every discovery member returns empty, and <see cref="GetContentToCreateAsync"/>
/// writes a single <c>robots.txt</c> to the output root. Useful for services
/// whose job is to produce a byte artifact (robots, search-index sidecars,
/// social-image generators) rather than contribute routed pages.
/// <para>
/// Backs how-to <c>/how-to/extensibility/emit-generated-artifacts</c>.
/// </para>
/// </summary>
public sealed class RobotsTxtContentService : IContentService
{
    private const string Body = """
        User-agent: *
        Allow: /
        Sitemap: /sitemap.xml
        """;
  
    /// <inheritdoc/>
    public string DefaultSectionLabel => "";
  
    /// <inheritdoc/>
    public int SearchPriority => 0;
  
    /// <inheritdoc/>
    public async IAsyncEnumerable<DiscoveredItem> DiscoverAsync()
    {
        await Task.CompletedTask;
        yield break;
    }
  
    /// <inheritdoc/>
    public Task<ImmutableList<ContentToCopy>> GetContentToCopyAsync()
        => Task.FromResult(ImmutableList<ContentToCopy>.Empty);
  
    /// <summary>
    /// Emits a single <c>robots.txt</c> at the site root. The generator runs
    /// only when output is written, so it can depend on late-stage state.
    /// </summary>
    public Task<ImmutableList<ContentToCreate>> GetContentToCreateAsync()
    {
        var artifact = new ContentToCreate(
            new FilePath("robots.txt"),
            () => Task.FromResult(Encoding.UTF8.GetBytes(Body)),
            "text/plain");
  
        return Task.FromResult(ImmutableList.Create(artifact));
    }
  
    /// <inheritdoc/>
    public Task<ImmutableList<ContentTocItem>> GetContentTocEntriesAsync()
        => Task.FromResult(ImmutableList<ContentTocItem>.Empty);
  
    /// <inheritdoc/>
    public Task<ImmutableList<CrossReference>> GetCrossReferencesAsync()
        => Task.FromResult(ImmutableList<CrossReference>.Empty);
}

GetContentToCreateAsync returns one ContentToCreate(OutputPath, ContentGenerator, ContentType) per artifact:

  • OutputPath is a FilePath relative to the output root. new FilePath("robots.txt") writes to /robots.txt; new FilePath("assets/og/home.png") writes to /assets/og/home.png.
  • ContentGenerator is a Func<Task<byte[]>> — deferred, not a prebuilt byte[]. The generator runs only when output is written, so it can depend on late-stage state (the final search index, the resolved xref map) without blocking discovery. Return Task.FromResult(bytes) when the content is ready synchronously.
  • ContentType is a string MIME. There is no enum. Common values: text/plain, text/markdown, application/json, application/xml, image/png.

The other members return empty — no routes for the crawler, no static files to copy, no sidebar rows, no xref ids:

  • DiscoverAsync yields nothing.
  • GetContentToCopyAsync returns ImmutableList<ContentToCopy>.Empty.
  • GetContentTocEntriesAsync returns ImmutableList<ContentTocItem>.Empty.
  • GetCrossReferencesAsync returns ImmutableList<CrossReference>.Empty.

DefaultSectionLabel and SearchPriority are read by consumers that group discovered items; since this service discovers nothing, they do not matter — return "" and 0.

Register the implementation

AddPennington does not auto-discover IContentService implementations — register directly on IServiceCollection. Transient is the right lifetime for this shape: the service is stateless and the container can build a fresh instance per resolution.

builder.Services.AddTransient<IContentService, RobotsTxtContentService>();

Result

The static build writes a single file at the output root:

User-agent: *
Allow: /
Sitemap: /sitemap.xml

Artifacts from GetContentToCreateAsync are emitted by the static build, not the live dev server. To serve the same bytes at request time during dotnet run, add a MapGet("/robots.txt", ...) endpoint that calls into the same generator — the /llms.txt endpoint wired by AddLlmsTxt is the reference for that pattern.

Verify

  • Run dotnet run --project examples/ExtensibilityLabExample -- build output and confirm output/robots.txt exists with the expected body.
  • Fetch /robots.txt from the dev server and expect a 404 — the artifact is a build-time output, not a live route.