Build 04/30: Eleventy Templates for Atom Feeds

Posted Thursday, October 16, 2025 by Sri. Tagged JOURNAL, ELEVENTY

I wanted my autoposts to Mastodon and Bluesky to have a single image, not all the images. Today's sharepiece is the Eleventy code I hacked up with a nice XSL stylesheet.

This is an Atom XML feed styled through XSL. Swanky!3 columns of web pages showing blog posts, demonstrating how an XML feed can be styled in the browser by XSL.3 columns of web pages showing blog posts, demonstrating how an XML feed can be styled in the browser by XSL. (full size image)

Today's Sharepiece: Eleventy Templates for Atom Feeds

My Eleventy atom feed template is designed to work with Echofeed.app, a service auto-posts to social media on your behalf by parsing entries from your syndication feed. It has the option of attaching images it finds in each entry as well.

My feed template produces a summary and also content. I've modified it so the summary contains a short amount of text that will fit into BlueSky's limit of 300 characters, and the content will contain the first image tag that it finds. I have Echofeed configured to use just the summary in each autopost, but to attach any images it finds in the content.

An additional feature is that I provide an XSL (eXtensible Stylesheet Language) file to provide styling for the feed XML. This means if you look at the raw XML file in a browser it will actually look like something!

For example, this yucky XML text...

  <entry>
<title>Build 03/30: Reviewing Old Work and Stories</title>
<link href="https://dsriseah.com/journal/2025/1015/" />
<updated>2025-10-15T09:56:06Z</updated>
<id>https://dsriseah.com/journal/2025/1015/</id>
<summary>Restarting my design business means new cards and a logo. I have a good idea what's important to me NOW, but that's very different from the PAST. What's changed? What's next?</summary>
<content type="html"><![CDATA[<img class="cssbox_thumb" src="https://dsriseah.com/_media/images/25/W1LxblCGGP-800.webp" tabindex=1 width="800" height="501" alt="Picture of a large steel nut, scratched-up on a white background" />]]></content>
</entry>

...will turn into...

The XML entry has been transformed through the magic of XSLT and XSL!(full size image)

...when you look at atom.xml on my serverAdmittedly, not a lot of people are going to be looking at my atom feed directly, but I think it's cool..

The Files

Prerequisites

The templates make use of custom filters added through my eleventy.js config:

  • atomFilter - removes posts that I don't want in the feed
  • head - returns only the first N posts
  • atom_excerpt - returns a 300 character-or-less excerpt of the main text
  • getFirstImage - returns the first <img> tag found and rewrites its src to an absolute URL.

NOTE: I am also using the version 1.2 of eleventy-plugin-rss. This plugin provides the filters absoluteUrl and absolutePostUrl used below.

Custom Filters used in atom-xml.njk

/** remove items from the collection that shouldn't be in the feed */
function atomFilter(collection) {
// custom logic for removing certain posts from the feed
return collection.filter(item => {
const skip = false; // conditions here
return !skip
});
}

/** return first n elements of the array, typically a collection */
function head(array,n) {
if (!Array.isArray(array) || array.length === 0) return [];
if (n < 0) return array.slice(n);
return array.slice(0, n);
}

/** creates a short excerpt */
function atom_excerpt(content, url = '', data = {}) {
const trimMark = ' [...]';
const MAXLEN = 285 - trimMark.length; // bluesky char limit 300, mastodon 500
const titleLength = (data.title || '').length;
const urlLength = url.length;
const _max_length = MAXLEN - titleLength - urlLength;

// get words from content
const text = content
.replace(/<[^>]*>/g, ' ') // remove any tags
.replace(/\s+/g, ' ') // remove multiple spaces
.trim(); // remove leading or trailing whitespace
// return excerpt if short
if (text.length <= _max_length) return text;
// otherwise find a word break then return
const words = text.split(' ');
let summary = '';
for (const word of words) {
const testSummary = summary ? `${summary} ${word}` : word;
if (testSummary.length + trimMark.length > _max_length) break;
summary = testSummary;
}
return summary + trimMark;
};

/** scans content for <img> tags, and rewrites relative urls to
* fully qualified urls */

function getFirstImage(content, baseUrl) {
const imgMatch = content.match(/<img[^>]*>/);
if (!imgMatch) return '';
let img = imgMatch[0];
// Convert src attribute
img = img.replace(/src="([^"]+)"/g, (match, url) => {
if (url.startsWith('http')) return match;
return `src="${baseUrl}${PATH.posix.join(url)}"`;
});
// remove srcset
img = img.replace(/srcset="([^"]+)"/g, '');
return img;
};

The Templates

Feed generation with atom-feed.njk

This generates the xml feed, which is written to the atom.xml file as its permalink.

---
permalink: 'atom.xml'
eleventyExcludeFromCollections: true
metadata:
title: 'DSri Seah: Blog Feed'
subtitle: 'Investigative Designer'
url: 'https://dsriseah.com'
route: '/'
feedUrl: 'https://dsriseah.com/atom.xml'
author:
name: 'DSri Seah'
---
<?xml version="1.0" encoding="utf-8"?>
<?xml-stylesheet href="/atom.xsl" type="text/xsl"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>{{ metadata.title }}</title>
<subtitle>{{ metadata.subtitle }}</subtitle>
<link href="{{ metadata.feedUrl }}" rel="self" type="application/atom+xml" />
<link href="{{ metadata.url }}" rel="alternate" type="text/html" hreflang="en" />
{% set posts = collections.journal | atomFilter -%}
<updated>{{ posts | getNewestCollectionItemDate | dateToRfc3339 }}</updated>
<id>{{ metadata.url }}{{metadata.route}}</id>
<author><name>Sri</name></author>
{%- for post in posts | reverse | head(15) %}
{%- set absolutePostUrl = post.url | url | absoluteUrl(metadata.url) -%}
{%- set firstImage = post.templateContent | getFirstImage(metadata.url) %}
<entry>
<title>{{ post.data.title }}</title>
<link href="{{ absolutePostUrl }}" />
<updated>{{ post.date | dateToRfc3339 }}</updated>
<id>{{ absolutePostUrl }}</id>
<summary>{{ post.templateContent | atom_excerpt(absolutePostUrl, post.data) | safe }}</summary>
{% if firstImage %}<content type="html"><![CDATA[{{ firstImage | safe }}]]></content>{% endif %}
</entry>
{%- endfor %}
</feed>

Styling with atom-feed-xsl.njk

This is the XSL styling file which writes to the atom.xsl permalink. It's referenced by the line...

<?xml-stylesheet href="/atom.xsl" type="text/xsl"?>

...in the feed generator template above. I think I got the base version of this from Robb Knight's Styling RSS and Atom Feeds article for the basic structure. I used the Claude Code IDE to help me add the various extra selectors because I certainly didn't want to look them up myself.

Behold!

---
permalink: 'atom.xsl'
eleventyExcludeFromCollections: true
---
<xsl:stylesheet
version="1.0"
xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
xmlns:atom="http://www.w3.org/2005/Atom"
exclude-result-prefixes="atom"
>
<xsl:output method="html" version="1.0" encoding="UTF-8" indent="yes" />
<xsl:template match="/">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1"
/>
<title>Web Feed<xsl:value-of select="atom:feed/atom:title"/></title>
<style type="text/css">
body {
max-width: 768px;
margin: 0 auto;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica,
Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol';
font-size: 16px;
line-height: 1.5em;
}
p {
margin:0;
margin-bottom: 1rem;
}
section {
margin: 30px 15px;
}
h1 {
font-size: 2em;
margin: 0.67em 0;
line-height: 1.125em;
}
h2 {
border-bottom: 3px dotted #00000020;
border-top: 3px dotted #00000020;
padding: 0.3em 0;
}
.alert {
background: #fff5b1;
padding: 0.5rem 1rem;
margin: 0 -12px;
}
.alert p {
margin: 0;
}
a {
text-decoration: none;
}
.entry {
margin-bottom: 4rem;
}
div.byline {
font-size: smaller;
text-transform: uppercase;
margin-bottom: 1rem;
}
div.content {
margin-bottom: 1rem;
}
.entry img {
max-width: 100%;
border: 1px solid #00000040;
}
.entry h3 {
margin-top: 1rem;
margin-bottom: 0rem;
font-size: 150%;
}
.entry p {
margin: 4px 0;
}
</style>
</head>
<body>
<section>
<div class="alert">
<p>
<strong>Subscribe</strong> to this feed by copying the URL from the address bar
into your newsreader app!
</p>
</div>
</section>
<section>
<xsl:apply-templates select="atom:feed" />
</section>
<section>
<h2 style="margin-bottom:2rem">Sri's Last 15 Posts</h2>
<xsl:apply-templates select="atom:feed/atom:entry" />
</section>
<section>
<h2>Visit <a href="{/atom:feed/atom:id}">
<xsl:value-of select="/atom:feed/atom:id" /></a> for all posts</h2>
</section>
</body>
</html>
</xsl:template>

<xsl:template match="atom:feed">
<h1><xsl:value-of select="atom:title" /></h1>
<p>
This RSS feed provides the latest blog posts from <i><b>
<xsl:value-of select="atom:title" /></b></i>.
</p>

<h2>What is an RSS feed?</h2>
<p>
An RSS feed is a data format that contains the latest content from a
website, blog, or podcast. You can use feeds to
<strong>subscribe</strong> to websites and get the
<strong>latest content in one place</strong>.
</p>

<p>
Visit <a href="https://aboutfeeds.com/">https://aboutfeeds.com</a> to get started with
newsreaders and subscribing.
</p>
</xsl:template>

<xsl:template match="atom:entry">
<div class="entry">
<h3>
<a target="_blank">
<xsl:attribute name="href">
<xsl:value-of select="atom:id" />
</xsl:attribute>
<xsl:value-of select="atom:title" />
</a>
</h3>
<div class="byline">
Posted
<xsl:call-template name="format-date">
<xsl:with-param name="date" select="atom:updated"/>
</xsl:call-template>
</div>
<xsl:if test="atom:content">
<div class="content"><xsl:value-of select="atom:content" disable-output-escaping="yes" /></div>
</xsl:if>
<xsl:if test="atom:summary">
<p><xsl:value-of select="atom:summary" disable-output-escaping="yes" /></p>
</xsl:if>
</div>
</xsl:template>

<xsl:template name="format-date">
<xsl:param name="date"/>
<xsl:variable name="year" select="substring($date, 1, 4)"/>
<xsl:variable name="month" select="substring($date, 6, 2)"/>
<xsl:variable name="day" select="substring($date, 9, 2)"/>

<xsl:choose>
<xsl:when test="$month='01'">January</xsl:when>
<xsl:when test="$month='02'">February</xsl:when>
<xsl:when test="$month='03'">March</xsl:when>
<xsl:when test="$month='04'">April</xsl:when>
<xsl:when test="$month='05'">May</xsl:when>
<xsl:when test="$month='06'">June</xsl:when>
<xsl:when test="$month='07'">July</xsl:when>
<xsl:when test="$month='08'">August</xsl:when>
<xsl:when test="$month='09'">September</xsl:when>
<xsl:when test="$month='10'">October</xsl:when>
<xsl:when test="$month='11'">November</xsl:when>
<xsl:when test="$month='12'">December</xsl:when>
</xsl:choose>
<xsl:text> </xsl:text>
<xsl:value-of select="number($day)"/>
<xsl:text>, </xsl:text>
<xsl:value-of select="$year"/>
</xsl:template>
</xsl:stylesheet>

So that's that! Sharepiece done! They should look nice in Chrome and Safari as of the time of this writingI've heard rumors that the Google Team wants to remove XSLT (the transformation engine that styles XML using XSL); Eric Meyer's article No, Google Did Not Unilaterally Decide to Kill XSLT has more context.. Firefox does not work, as it doesn't interpret the html inside of <![CDATA[..]]> and instead dumps it as plain text.

Also...I actually am not sure it will actually work since I need to publish the new RSS before EchoFeed will try to do something with it. I didn't find an obvious test mode in EchoFeed that would allow me to fetch and show me how it processes it.

All Building Challenge Posts

This challenge starts October 11th and ends when 30 artifacts have been posted. Weekends are exempt from production.

URSYS Web App Template

Embedded TypeScript Apps in Eleventy

A Review of Old Work and Stories

Eleventy Templates for Atom Feeds

BUILD CHALLENGE COMMENTARY

I wanted to do something quick today, so I picked an easy task: add image content to the site feed which would make feed readers look a little nicer. As the feed itself is already generated by my own template, I figured it wouldn't take long.

Oh how wrong I was. I underestimated how fussy I would get about making this new feature work. Even using AI (in the form of Claude Code) didn't help, as this just let me try more things to make everything just right...

It took all day, but it does look pretty great!

On the other hand, I still want to spend less time on my daily activities, so I have enough time for adulting chores and self care time. I'm just barely finishing each blog post before midnight.

BONUS ACHIEVEMENTS

Using the atom feeds is probably the most convenient way to browse recent content on the blog:


We chat about personal projects and challenges on the DSRI Discord Community Server every day. Come visit! Maybe you'll make some friends!

Or reach out to me at Mastodon or Bluesky