Antora is a static site generator primarily meant for documentation in which the original content is written and maintained in AsciiDoc.

In our documentation setup, some of the pages contain useful updates (like release notes) that would be helpful to automatically broadcast to other channels when the Antora site is rebuilt.

An RSS feed is a good candidate for publishing such updates, but this isn't something that Antora offers out of the box.

Fortunately, it only takes a bit of JavaScript to customize Antora.

With the help of Antora extensions, we can hook into different parts of the build process and gather data from the current state of the build.

This means we could create an RSS file based on the pages that Antora generates and package it with the other static files before the site is deployed.

Ideally, the RSS feed would look like the following, where we have summarized the latest release(s) and linked to the full release notes in the documentation:

RSS feed
			<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>My Product Updates</title>
    <link>https://www.mysite.com/feed.rss</link>
    <description>The latest updates about my product</description>
    <lastBuildDate>Fri, 14 Mar 2025 11:00:00 GMT</lastBuildDate>
    <item>
        <title>My Product, v1.0.1</title>
        <link>https://www.mysite.com/my-product/release-notes/</link>
        <description>This release did some things</description>
        <pubDate>2025-03-10</pubDate>
    </item>
  </channel>
</rss>
		

Let's walk through how we would create this.

First, Antora extensions are written as JavaScript files and referenced in the playbook file under `antora > extensions`.

For example:

antora>extensions
			antora:
  extensions:
    - './scripts/my-extension.js'
		

In the JavaScript file, we'll export a "register function" that creates an event listener on the `sitePublished` event.

For example:

Register function
			module.exports.register = (context) => {
    context.on('sitePublished', ({ playbook, contentCatalog }) => {
        
    });
};
		

The `sitePublished` event happens at the end of the Antora build process, but we could hook into earlier events like `playbookBuilt` or `navigationBuilt` if we wanted to intercept specific things that Antora does.

One use case that Antora suggests is to conditionally unpublish pages during the `navigationBuilt` phase.

For us, we don't want to change anything about Antora. We just want to parse some of the completed pages.

In particular, we want to pull out the highlights from all of our "release notes" pages.

Release notes could span multiple components (i.e., products) and multiple versions of those components.

In our `sitePublished` callback, we can use the `contentCatalog` object to loop over every component and version, looking for anything marked as a "page" and then filtering out everything but the release notes pages.

For example:

contentCatalog object to filter entities marked "page"
			const components = contentCatalog.getComponents();

components.forEach(component => {
    // find each version for each component
    component.versions.forEach(version => {
        // look for pages
        const releaseNotesPage = contentCatalog
            .findBy({ 
                component: component.name, 
                version: version.version, 
                family: 'page', 
                module: 'ROOT'
            })
            // filter down to only the release notes
            .filter(page => page.out.path.includes('release-notes'))[0];
    });
});
		

If we only had one component/product with one version, then we could simplify this search in the following manner:

Simplified search option
			const releaseNotesPage = contentCatalog
    .findBy({ 
        component: 'my-product',
        family: 'page', 
        module: 'ROOT'
    })
    .filter(page => page.out.path.includes('release-notes'))[0];
		

Now that we have the page (`releaseNotesPage`), we can parse out the data for the RSS feed.

In the original AsciiDoc, the release notes are written as follows:

Parsing data for RSS feed
			== Version 1.0.1, 2025-03-10

Brief description of the release...

Followed by more details...

== Version 1.0.0, 2025-01-10

Brief description of the release...

Followed by more details...
		

For the RSS feed, we're only concerned with the latest release announcement (the first `==` header) and the first paragraph underneath.

After Antora processes this content, those details will look like the following HTML:

After Antora processing
			<div class="sect1">
    <h2>Version 1.0.1, 2025-03-10</h2>
    <div class="sectionbody">
        <div class="paragraph">
            <p>
                Brief description of the release...
            </p>
        </div>
    </div>
</div>
		

At this point, we could use regular expressions to capture the relevant details.

Personally, I like to use the npm package, Cheerio.

With Cheerio, we can write jQuery-like selectors to find elements in a given string/document.

In this case, we're looking for the `<h2>` and `<p>` elements.

Using Cheerio and other data from the Antora callbacks, we can start piecing together what constitutes an RSS feed item.

For example:

Creating an RSS feed item with Cheerio and Antora
			// load page content into cheerio
const $ = cheerio.load(releaseNotesPage.contents.toString());

// consolidate details
const headerText = $('h2').first().text().split(', ');
const rssItem = {
    title: `${version.title}, v${headerText[0].split(' ')[1]}`,
    description: $('.paragraph p').first().text(),
    pubDate: headerText[1],
    link: playbook.site.url + releaseNotesPage.pub.url
};

// add to array of all rss items
items.push(rssItem);
		

Remember that this is only capturing the latest release announcement on the page.

If we wanted to include every `==` header in the RSS feed, then we could use a jQuery `each()` method instead to loop over every `sect1` section.

For example:

Looping every sect1
			$('.sect1').each((i, section) => {
    const headerText = $(section).find('h2').text();
    ...
});
		

Lastly, we can pass the collected RSS items into a template as follows:

Passing RSS items into template
			const feed = `
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>My Product Updates</title>
    <link>${playbook.site.url}/feed.rss</link>
    <description>The latest updates about my product</description>
    <lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
    ${items.map(item => `
    <item>
        <title>${item.title}</title>
        <link>${item.link}</link>
        <description><![CDATA[${item.description}]]></description>
        <pubDate>${item.pubDate}</pubDate>
    </item>`
    ).join('')}
  </channel>
</rss>
`;
fs.writeFileSync(`./build/site/feed.rss`, feed.trim());
		

We just need to ensure that we write the `feed.rss` file to the same directory that Antora outputs the HTML files (`build/site` by default).

Now, whenever Antora builds the documentation site, the RSS feed is included, and other services can subscribe to it.