Atom and RSS feeds

Punk::Feed serves Atom 1.0 and RSS 2.0 from one set of entries. As a plugin it installs a keyword of its own beside Punk's - feed - through the same mechanism any plugin can use.

package MyApp;
use Punk;
use Punk::Plugin::Feed;              # compile time: the keyword

host 'https://example.com';

plugin 'Feed' => {                   # runtime: the configuration
    title       => 'Example',
    author      => 'A Name',
    description => 'Notes on things',
    ttl         => 3600,
    limit       => 20,
};

feed 'Web::Feed#posts';              # the default feed

feed releases => {                   # a second one
    title   => 'Example releases',
    limit   => 5,
    entries => 'Web::Feed#releases',
};

What it serves

GET /feed.xml        Atom 1.0, the default feed
GET /feed.rss        RSS 2.0, the default feed
GET /feed/NAME.xml   Atom 1.0, a named feed
GET /feed/NAME.rss   RSS 2.0, a named feed

From the site root regardless of any mount prefix, because a feed URL goes into a <link rel="alternate"> and into a reader's database, and neither follows a prefix that moved. path moves the stem and format decides which of the two are registered.

Routes exist only for feeds that were declared. An application that loads the plugin and declares nothing serves no feed routes at all, rather than a route that could only ever answer 404 - which would tell a reader the feed is broken instead of that it was never offered.

Where the entries come from

A section is a 'Controller#method' target, exactly like a route's, or a closure when it is too small to earn a method:

feed 'Web::Feed#posts';                        # the default feed
feed news => 'Web::Feed#news';                 # a named one
feed news => { title => 'News', entries => sub { ... } };
package MyApp::Controller::Web::Feed;
use Punk::Controller;

sub posts {
    return map +{
        loc       => "/posts/$_->{slug}",
        title     => $_->{title},
        updated   => $_->{updated},
        published => $_->{published},
        summary   => $_->{excerpt},
        content   => $_->{body},
        category  => $_->{tags},
    }, MyApp->model('Post')->recent;
}

A section takes no context. It runs at to_app and again when the TTL has passed, not per request, so there is no $c to hand it.

One argument that is a bare string is a name whose body was left off - feed 'news'; is the mistake it looks like. A string holding a # is a target for the default feed instead, which is how Punk tells the two apart everywhere else.

Declaring a name twice replaces rather than appends and keeps its position, so a base class can declare a feed and a subclass override it without the route set reshuffling.

The entry

loc, title and updated are required. Everything else - id, published, summary, content, author, category, enclosure - is optional, and a field the feed has no use for is ignored rather than refused, because mapping straight over database rows is the ordinary way to write a section.

An entry missing one of the three is dropped, with a warning. Atom makes all three mandatory and a reader handed a document that violates that rejects the whole document rather than the offending entry, so one unusable row must not be able to empty the feed. Defaulting the date to the build time is the tempting repair and it is worse: it tells every subscriber that every item changed on every deploy.

Dates are an epoch, an ISO-8601 instant with an optional offset, or a bare YYYY-MM-DD read as midnight UTC. Entries are sorted newest first, loc ascending where two share a date, then truncated to limit. The tiebreak is not tidiness: without it a database returning rows in its own order would make every rebuild a different file, and a reader comparing bytes would report changes that did not happen.

The origin is configuration

host supplies it, or an explicit base overrides it. The request is never consulted, and this matters more than it does for the sitemap: a request carrying Host: evil.example would produce a feed naming that host for every item, the owner's own request would produce a correct one so nothing would look wrong, and every reader that fetched the poisoned copy would keep it for as long as somebody stayed subscribed.

An application with neither croaks at to_app rather than at the plugin line, so host may be declared on either side of it.

With a host allowlist, an allowlisted tenant is answered with a document naming itself and carrying its own ETag. The entries are shared - a tenant document is rendered from the records the build already collected, so the sections run once per ttl however many hosts ask.

Autodiscovery

<head>
  ...
  {% raw feed_links %}
</head>

$c->feed_links prints the <link rel="alternate"> tags for every declared feed, in the formats actually served and already escaped. It exists because autodiscovery is the only way a browser or a reader finds the feed from the page, and hand-writing those attributes into a layout is where the URL goes stale the first time path changes.

A reader is a fetcher on a schedule

It will ask for the same URL every few minutes for years, and between rebuilds the answer is bytes that already exist. Every response carries an ETag over those bytes and a Last-Modified holding the newest entry's date, and both are honoured: If-None-Match wins outright when present, and the date is understood too, because some readers only ever send that one. Cache-Control: max-age follows from ttl.

This is built in rather than left to ConditionalGet: the bytes and their timestamp are both known at build time.

The feed timestamp is the newest entry's date, never now. A rebuild that found nothing new produces the same bytes as the one before it - otherwise every reader records a change on every TTL. RSS spells that element <lastBuildDate>, and the name is the trap.

Rebuilds, and the staleness they buy

The documents are rendered at to_app and rebuilt when ttl seconds have passed, so a feed is stale by up to that long. That is worth saying rather than engineering away: a reader learning about a post an hour late is a reader behaving normally.

There is no stampede to guard against inside a worker. Hyperman is single-threaded and a worker serves its requests one after another, so the second of two simultaneous requests finds what the first built.

If a rebuild's section dies it warns and keeps the entries from the last good build. Publishing nothing instead would empty the feed, and a reader handed an empty feed concludes every item was deleted - a database away for a minute must not look like a site that deleted its archive. On the first build there is nothing to fall back on, so there the feed really is empty.

Atom or RSS

Both, from one set of entries. What each cannot say is worth knowing before pointing readers at one.

RSS has a single date element, so an entry with both published and updated loses one; it has no content element in the core specification, so summary is preferred and content stands in only when there is none. Atom has no language, which is why that option is documented as RSS-only rather than quietly ignored. RSS specifies <author> as an email address - a name goes there instead, which every reader accepts and which does not publish the address.

An entry's id defaults to its absolute URL, which is right for most sites. What it costs: a post whose URL changes is a post every subscriber sees twice. id => "tag:example.com,2026:post/$slug" is the durable form, and RSS marks a supplied id isPermaLink="false" because a tag: URI is not something a reader should fetch.

Escaping, and one thing to know about text

Every value is XML-escaped and every URL percent-encoded first, in that order - a path holding a space is not a URL at all, and escaping alone would produce a well-formed document full of links that do not work. A query string keeps its own rules, so /article?id=5 stays a query rather than becoming a request for a file with a ? in its name.

There is no CDATA anywhere: content containing ]]> would break straight out of it, and that sequence turns up on its own in code samples.

Text reaches the document as UTF-8 whichever way Perl was storing it - including "Caf\x{e9}", which Perl keeps unflagged as a single byte and which would otherwise put invalid UTF-8 into a document declaring encoding="UTF-8". That is fatal rather than cosmetic: a reader rejects the whole thing, exactly as it does for a bare &.

A worked example

The distribution ships one: example/Blog is a punk new application with two feeds, controller-method sections, autodiscovery in the layout and a test that walks the lot.