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.