Publish a custom feed from a content service
Build the same RSS pattern BlogSite uses for /rss.xml — a content service that caches records, an XML builder method, and a MapGet endpoint — for podcast episodes, conference sessions, changelogs, or any non-blog content type.
BlogSiteOptions.EnableRss only applies to BlogSiteFrontMatter records. For any other content type — podcast episodes, conference sessions, a changelog — reuse the pattern BlogSite builds on: a content service caches the records, a Task<string> builder turns them into feed XML, and a MapGet endpoint serves that XML. Every MapGet endpoint is fetched and baked during the static build, so the feed file lands in output/ next to every other page with no extra registration.
The reference implementation is core's RssFeedWriter.WriteXml — called from BlogPostQuery.GetRssXmlAsync and the MapGet in UseBlogSite. This guide walks the three points you adapt in that pair, so the same shape can carry a podcast feed (with the iTunes namespace), an events feed (with iCalendar enclosures), or any custom format.
Before you begin
- A bare
AddPenningtonhost (see Create your first Pennington site) or any host where the records come from a customIContentService— see Source content from outside the markdown pipeline for the discovery shape. CanonicalBaseUrlset onPenningtonOptions,DocSiteOptions, orBlogSiteOptions. Without it,<link>and<guid>emit relative URLs that aggregators cannot follow.- For the BlogSite-shipped
/rss.xml, use Publish an RSS feed instead — this guide is for the other shapes.
Build the feed XML on the content service
Order the cached records and emit XML with System.Xml.Linq. Core's RssFeedWriter.WriteXml — which the BlogSite feed reuses — is the reference body:
public static string WriteXml(
string siteTitle,
string siteDescription,
string? canonicalBaseUrl,
IEnumerable<RssFeedItem> items)
{
var canonicalBase = canonicalBaseUrl?.TrimEnd('/') ?? string.Empty;
XNamespace atom = "http://www.w3.org/2005/Atom";
var channel = new XElement("channel",
new XElement("title", siteTitle),
new XElement("link", string.IsNullOrEmpty(canonicalBase) ? "/" : canonicalBase + "/"),
new XElement("description", siteDescription));
if (!string.IsNullOrEmpty(canonicalBase))
{
channel.Add(new XElement(atom + "link",
new XAttribute("href", canonicalBase + "/rss.xml"),
new XAttribute("rel", "self"),
new XAttribute("type", "application/rss+xml")));
}
var ordered = items
.Where(i => i.PublishDate.HasValue)
.OrderByDescending(i => i.PublishDate!.Value);
foreach (var item in ordered)
{
var url = string.IsNullOrEmpty(canonicalBase)
? item.Url.Value
: canonicalBase + item.Url.Value;
var entry = new XElement("item",
new XElement("title", item.Title),
new XElement("link", url),
new XElement("guid", new XAttribute("isPermaLink", "true"), url));
if (!string.IsNullOrEmpty(item.Description))
{
entry.Add(new XElement("description", item.Description));
}
if (item.PublishDate.HasValue)
{
entry.Add(new XElement("pubDate", item.PublishDate.Value.ToUniversalTime().ToString("r")));
}
if (!string.IsNullOrEmpty(item.Author))
{
entry.Add(new XElement("author", item.Author));
}
channel.Add(entry);
}
var rss = new XElement("rss",
new XAttribute("version", "2.0"),
new XAttribute(XNamespace.Xmlns + "atom", atom.NamespaceName),
channel);
var doc = new XDocument(new XDeclaration("1.0", "utf-8", null), rss);
return doc.Declaration + Environment.NewLine + doc;
}
The pieces to adapt for your records:
- The cache.
DiscoverAsyncand the feed builder read from one cached list loaded once per generation, so the source files are parsed once and both paths see the same records. TheLazy<T>cache that already backsDiscoverAsyncworks here without changes. - The filter. BlogSite drops posts without a
Date. Replace this with whatever predicate keeps an entry in the feed (IsPublished,Status == Released, future-date skip viaTimeProvider). - The ordering. Newest-first is conventional for RSS; podcast aggregators expect it.
- Absolute URLs. Prefix every
<link>and<guid>withcanonicalBase. Relative paths break in feed readers. - The atom self-link.
<atom:link rel="self" .../>tells readers where the feed canonically lives. Match the URL you map below. - Per-item elements. Keep
<title>,<link>,<guid>. Add what your content type needs:<category>per tag,<enclosure>for media attachments, namespaced elements for iTunes/Atom/Dublin Core.
Wire DI so the endpoint and the discovery list share one instance
Register the concrete service, then forward IContentService to the same instance with a transient indirection. Two separate registrations would let the container hand the endpoint a fresh copy with a cold cache:
// File-watched when the service reads from disk; AddSingleton<T>() when the
// data source is in-process. The transient IContentService wrapper resolves
// against the current factory-managed instance so file-change recreates flow
// through to both the endpoint and the pipeline.
services.AddFileWatched<PodcastContentService>();
services.AddTransient<IContentService>(sp =>
sp.GetRequiredService<PodcastContentService>());
AddSingleton<IContentService> here would cache the first file-watched copy and never refresh — the transient wrapper avoids that trap. The full lifetime contract for AddFileWatched<T> and the stale-data failure mode is in Register the service.
Map the endpoint
Inject the concrete service into a MapGet handler that returns the XML with the right MIME type:
app.MapGet("/feed.xml", async (PodcastContentService service) =>
Results.Content(await service.GetRssXmlAsync(), "application/rss+xml"));
Two reasons this single line carries both dev and build:
- Dev mode serves
/feed.xmlstraight from the handler. - Static build fetches every
MapGetendpoint over HTTP through the live pipeline and writes each body tooutput/— sooutput/feed.xmlis baked from the same handler. No artifact-service registration is needed.
Reach for IArtifactContentService instead when the URL set is dynamic or derived from the rendered corpus — search shards and llms.txt files ship that way. See Emit generated output artifacts for that shape.
Adapt for podcast feeds (iTunes namespace)
A podcast RSS feed extends the same XML with the iTunes namespace plus per-item duration, episode number, and enclosure elements. Declare the namespace on the root and add the children inside the per-item loop:
XNamespace atom = "http://www.w3.org/2005/Atom";
XNamespace itunes = "http://www.itunes.com/dtds/podcast-1.0.dtd";
var rss = new XElement("rss",
new XAttribute("version", "2.0"),
new XAttribute(XNamespace.Xmlns + "atom", atom.NamespaceName),
new XAttribute(XNamespace.Xmlns + "itunes", itunes.NamespaceName),
channel);
// Per-item additions inside the feed builder's item loop:
entry.Add(
new XElement(itunes + "duration", episode.Duration.ToString(@"hh\:mm\:ss")),
new XElement(itunes + "episode", episode.EpisodeNumber),
new XElement(itunes + "season", episode.SeasonNumber),
new XElement("enclosure",
new XAttribute("url", absoluteAudioUrl),
new XAttribute("length", episode.AudioBytes),
new XAttribute("type", "audio/mpeg")));
Channel-level iTunes elements (<itunes:image>, <itunes:category>, <itunes:owner>) sit alongside the existing <title> / <link> / <description> block. Apple's Podcasters Connect page is the authoritative list.
Adapt for Atom feeds
Atom 1.0 uses a different root and element vocabulary. The shape is identical — same cache, same builder method, same MapGet — only the XML changes. The sketch below shows the element structure; canonicalBase, ordered, and absoluteUrl are the same locals the RSS builder above sets up, dropped here for focus:
XNamespace atom = "http://www.w3.org/2005/Atom";
var feed = new XElement(atom + "feed",
new XElement(atom + "title", _options.SiteTitle),
new XElement(atom + "id", canonicalBase + "/"),
new XElement(atom + "updated", DateTime.UtcNow.ToString("o")));
foreach (var entry in ordered)
{
feed.Add(new XElement(atom + "entry",
new XElement(atom + "title", entry.Title),
new XElement(atom + "id", absoluteUrl),
new XElement(atom + "updated", entry.Date.ToString("o")),
new XElement(atom + "link", new XAttribute("href", absoluteUrl))));
}
Serve at a separate path (/atom.xml) with application/atom+xml. Nothing stops a site from publishing both RSS and Atom — register two endpoints against the same service.
Verify
- Run
dotnet runand fetch/feed.xml. The response is the right MIME type with one item per record. - Run
dotnet run -- build outputand confirmoutput/feed.xmlexists with the same body. The build crawler reuses the live endpoint. - Validate the XML externally —
xmllint --noout feed.xmlcatches well-formedness errors. For podcasts, run the file through Apple's podcast validator before submitting to directories. - Edit a source record and refetch
/feed.xmlin dev. The file-watched cache rebuilds and the change appears without a restart.
Related
- How-to: Publish an RSS feed (BlogSite)
- How-to: Source content from outside the file system
- How-to: Source content from a remote API
- How-to: Emit generated output artifacts
- Background: The content pipeline and union types