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
- A working Pennington site on bare
AddPennington(see Create your first Pennington site if not). - Familiarity with the four-stage pipeline at a conceptual level (The content pipeline and union types).
For a working setup, see examples/ExtensibilityLabExample — RobotsTxtContentService.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:
OutputPathis aFilePathrelative to the output root.new FilePath("robots.txt")writes to/robots.txt;new FilePath("assets/og/home.png")writes to/assets/og/home.png.ContentGeneratoris aFunc<Task<byte[]>>— deferred, not a prebuiltbyte[]. 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. ReturnTask.FromResult(bytes)when the content is ready synchronously.ContentTypeis 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:
DiscoverAsyncyields nothing.GetContentToCopyAsyncreturnsImmutableList<ContentToCopy>.Empty.GetContentTocEntriesAsyncreturnsImmutableList<ContentTocItem>.Empty.GetCrossReferencesAsyncreturnsImmutableList<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 outputand confirmoutput/robots.txtexists with the expected body. - Fetch
/robots.txtfrom the dev server and expect a 404 — the artifact is a build-time output, not a live route.
Related
- Reference: Content pipeline interfaces
- How-to: Source content from outside the file system
- Background: The content pipeline and union types