Adding a RSS Feed to Your Blog

Video

Kirby has a built-in feature called content representations that fits perfectly for such a task.

How content representations work

The idea is simple. Kirby maps URL extensions to template files. We already have a posts.php template that renders when someone visits /posts. If we create a file called posts.xml.php, Kirby will automatically serve that when someone visits /posts.xml. This is perfect, since our RSS feed is just another representation of the same content – ha that name fits quite well i think :D

We still have access to our $page object and all the content and methods of that page.

You can also use this for JSON, or really any format you want. Worth checking out the Kirby docs on content representations if you want to dive deeper.

A quick note on RSS

Before we write the template, one thing: RSS is just XML. If you've never written XML, it's basically like HTML but stricter. Every tag must be closed, attribute values must be quoted, and special characters need to be escaped.

For RSS specifically, there's a spec that defines a set of elements like <channel>, <item>, <title>, <description> and so on. The RSS 2.0 spec at rssboard.org is a good reference, or just ask an LLM to generate it for you.

Building the template

Let's create site/templates/posts.xml.php.

RSS requires dates in RFC 2822 format and I'm using the PHP intl formatter,so we'll write a small helper script to create the timestamps.

function rssDate($field): string
{
  return $field->toDate('EEE, dd MMM yyyy HH:mm:ss') . ' ' . $field->toDate('xx');
}

Then we grab our posts, limited to the 20 most recent:

$posts = $page->children()->published()->sortBy('published', 'desc')->limit(20);

Now the actual XML output.

For escaping XML, we can use Kirby's built-in Escape::xml() method from the Toolkit (available as esc($value, 'xml')). It encodes special characters so they don't break the XML structure.

We set the content type header and write the RSS channel metadata: title, description, link, language, and the build date from the most recent post.

<?php header('Content-type: application/rss+xml; charset=utf-8'); ?>
<?= '<?xml version="1.0" encoding="UTF-8"?>' ?>
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
  <channel>
    <title>
      <?= esc($site->title() . ' - ' . $page->title(), 'xml') ?>
    </title>
    <description><?= esc($page->metaDescription()->or($site->metaDescription()), 'xml') ?></description>
    <link><?= esc($page->url(), 'xml') ?></link>
    <atom:link href="<?= esc($page->url() . '.xml', 'xml') ?>" rel="self" type="application/rss+xml" />
    <language><?= $kirby->language()->code() ?></language>
    <lastBuildDate><?= rssDate($posts->first()->published()) ?></lastBuildDate>
    <?php foreach ($posts as $post): ?>
      <item>
        <title><?= esc($post->title(), 'xml') ?></title>
        <link><?= esc($post->url(), 'xml') ?></link>
        <guid><?= esc($post->url(), 'xml') ?></guid>
        <pubDate><?= rssDate($post->published()) ?></pubDate>
        <description>
          <![CDATA[<?= $post->main()->toBlocks()->excerpt(300) ?>]]>
        </description>
        <content:encoded>
          <![CDATA[<?= $post->main()->toBlocks() ?>]]>
        </content:encoded>
        <?php if ($author = $post->author()->toUser()): ?>
          <author><?= esc($author->email() . ' (' . $author->name() . ')', 'xml') ?></author>
        <?php endif ?>
      </item>
    <?php endforeach ?>
  </channel>
</rss>

Let's walk through what's happening.

The <channel> element wraps everything and describes the feed itself. The <atom:link> with rel="self" is a self-referencing URL, which some feed readers expect. The <lastBuildDate> tells readers when the feed was last updated, and we just use the date of the most recent post for that.

For each post, we output an <item>. That's the main building block in RSS, each item is one entry in the feed.

A few things worth noting:

  • The <guid> is a globally unique identifier for the item. We just use the URL.
  • The <description> is a short excerpt. I'm using the first 300 characters of the rendered blocks.
  • The <content:encoded> contains the full content. Both description and content:encoded are wrapped in CDATA sections, so we can include HTML without having to escape every single tag.

If your posts have an author field, the author block at the bottom adds it to the feed. If not, the if check just skips it.

Making the feed discoverable

To make the feed discoverable, add an RSS autodiscovery tag in your <head>, in my case in the layout snippet:

<link rel="alternate" type="application/rss+xml" title="Posts" href="<?= $page->url() . '.xml' ?>">

This is what allows browsers and feed readers to automatically find your feed. And of course you can link to it visually in your posts template as well:

<a href="<?= $page->url() . '.xml' ?>">RSS Feed</a>