Rebuilding my blog in JavaScript: feeds

Really Not Simple Syndication

Two weeks ago I posted about using JavaScript as a templating language in 11ty, as a “fun” learning experiment. I showed off the base layout and the start of the home page template. Since then, I’ve made significant progress:

That last item was challenging—more evenings were spent on the feed templating than the rest of the project combined. I had a laundry list of features:

The file structure (yes there’s multiple) ended up looking like this:

And the way they’re used looks like this:

+-------------------------------+
| layouts/feed.11ty.js          |
| +---------------------------+ |
| | blog/feed.11ty.js         | |
| | +-----------------------+ | |
| | |shortcodes/feedItems.js| | |
| | +-----------------------+ | |
| +---------------------------+ |
+-------------------------------+

I’ll be your tour guide through this spaghetti monster, starting with the shortcode.

The shortcode

Shortcodes in *.11ty.js templates are JavaScript functions that are available globally, which means I can use them pretty much anywhere, and even pass in other shortcodes/functions.

In shortcodes/feedItems.js, I have two top-level functions: escapeHtml() and the main one attached to module.exports.

Here’s what escapeHtml() looks like:

/**
 * Escape HTML entities in a string.
 * @param {String} str - The string to escape.
 * @return {String} - The escaped string.
 * @see {@link https://stackoverflow.com/a/6234804/11985346 Stack Overflow}
 */
function escapeHtml(str) {
	let chars = {
		"&": "&",
		"<": "&lt;",
		">": "&gt;",
		'"': "&quot;",
		"'": "&#039;",
	};
	let keys = Object.keys(chars);
	let regex = new RegExp(`[${keys.join("")}]`, "g");

	return str.replaceAll(regex, (char) => chars[char]);
}

This one starts with an object with a list of characters I need to replace with their respective HTML entities, in order to “escape” them for valid use inside an XML document (the feed).

Then we grab each of the characters with Object.keys() and assign it to the keys variable, which is then passed to the RegExp constructor in the form of a template literal—this way I don’t need to specify the characters twice. Since the output of Object.keys is an array, I used the join() method to strip out the quotes and return a string of characters (&<>"') to build the regular expression I need.

And then in the return statement, I used the replaceAll() method to take the regex and iterate through the character object, replacing each occurrence in str with its respective entity.

Escaping HTML is possible with the Web API in the browser, but things are different in Node—contrary to the name, there is no node interface or DOM manipulation, so it’s duct tape time.

⚠️ Only do this at home ⚠️

Escaping HTML yourself is frowned upon, but since this is a learning project for me, I’m trying to solve every problem myself before adding a dependency—even if the lesson learned is “don’t reinvent the wheel!” yet again. Next time I’ll reach for html-entities or use the filters available in Nunjucks and Liquid.

On to the next function:

/**
 * Returns markup for an Atom feed for a given collection
 * @param {Object} items - Eleventy collection object
 * @param {Object} data - Eleventy data object
 * @param {Number} limit - Number of entries to include
 * @param {Boolean} email - Whether or not to show the Reply via Email link on entries
 * @return {String} - Atom feed markup
 */
module.exports = function (items, email, limit) {
	/**
	 * Limit the items object to the first n entries.
	 * @param {Object} items - Eleventy collection object
	 * @param {Number} limit - Number of entries to include
	 * @return {Object} - Limited Eleventy collection object
	 */
	let entries = items.slice(0, limit);

	/**
	 * Import metadata from _data/metadata.js
	 * @type {Object}
	 */
	const metadata = require("../../_data/metadata.js");

	/**
	 * Generate the entries markup.
	 * @return {String} - Atom feed markup
	 */
	return `
	${entries
		.map(
			(item) => `
	<entry>
		<title>${item.data.title}</title>
		<link href="${metadata.url}${item.url}" rel="alternate" type="text/html"/>
		<id>${metadata.url}${item.url}</id>
		<published>${item.data.page.date.toISOString()}</published>
		<updated>${
			item.data.updated
				? `${item.data.updated.toISOString()}`
				: `${item.data.page.date.toISOString()}`
		}</updated>
		${item.data.description ? `<summary>${item.data.description}</summary>` : ""}
		<content type="html">
			${escapeHtml(item.templateContent)}
			${escapeHtml(
				email
					? `<p><a href="mailto:${metadata.author.email}?subject=Re: ${item.data.title}">Reply via email</a></p>`
					: ""
			)}
		</content>
	</entry>`
		)
		.join("")}
`;
};

I’m pulling in 3 arguments here: items, email, and limit. items is required, the second two will fail gracefully if they’re not passed in when I call the shortcode in my templates later—and by gracefully, I mean there won’t be an email link, and 11ty will happily generate every single item in the collection object.

In the first declaration, I took the items object and sliced it to create a new object with a number of entries between 0 and whatever limit is. Then I assigned that new object to entries. Next, I had to pull in my global data file _data/metadata.js for the site URL and email—not sure why, but global data is either not available to shortcodes, or I can’t find it.

Metadata solved, I iterated over entries with Array.map() in the return statement. The rest is data from the object and one bit of global data for the email address. One important thing I had to do is make sure all the dates were formatted according to the ISO 8601 standard, which the Atom spec requires. That’s accomplished with .toISOString().

The feed template

One thing I like about Hugo is that it creates feeds for sections and tags. I don’t know how to accomplish tag feeds, but for sections, my solution is to create a feed.11ty.js template file in each section I want a feed for, which is currently the blog.

This is the entire contents of the template:

/**
 * @file Defines the blog post feed template
 */

/**
 * Frontmatter in JavaScript templates
 * @type {Object}
 * @see {@link https://www.11ty.dev/docs/data-frontmatter/#javascript-object-front-matter 11ty docs}
 */
exports.data = {
	title: "Blog",
	layout: "layouts/feed.11ty.js",
	eleventyExcludeFromCollections: true,
	permalink: "blog/index.xml",
};

/**
 * Uses the feedItems shortcode to generate an Atom feed for the blog collection.
 * @param {Object} data - Eleventy data object
 * @returns {String} - Atom feed markup
 */
exports.render = function (data) {
	return `
		${this.feedItems(data.collections.blog, true, 1)}
	`;
};

Two things going on here:

  1. I set the title of the feed, told 11ty to use the feed.11ty.js layout for this template, excluded it from collections so it doesn’t get any funny ideas about infinite recursion and render itself inside of itself, and set the permalink to match the existing one on the Hugo version of my site
  2. I called the feedItems shortcode from earlier and passed in the collection I need feed entries for, true for the email link, and the number of entries to return

The feed layout

layouts/feed.11ty.js:

/**
 * @file Defines the template for the RSS feed.
 */

/**
 * Returns markup for an Atom feed for a given collection.
 * @param {Object} data - Eleventy data object
 * @param {Object} collection - Eleventy collection object
 * @returns {String} - Atom feed markup
 */
exports.render = function (data) {
	return `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="${data.metadata.language}">
	<title>${data.title}${data.metadata.title}</title>
	<subtitle>Recent content from the ${data.title} section on ${data.metadata.title}</subtitle>
	<updated>${data.generated}</updated>
	<id>${data.metadata.url}/</id>
	<link href="${data.metadata.url}${data.page.url}" rel="self" type="application/atom+xml"/>
	<link href="${data.metadata.url}/" rel="alternate" type="text/html"/>
	<generator uri="https://www.11ty.dev/" version="${data.eleventy.version}">Eleventy</generator>
	<rights>© ${new Date().getFullYear()} ${data.metadata.author.name}</rights>
	<author>
		<name>${data.metadata.author.name}</name>
		<email>${data.metadata.author.email}</email>
	</author>
	${data.content}
</feed>`;
};

This layout template adds the top level elements in the resulting XML document. The markup doesn’t change between different sections, so it makes sense to make it reusable for the individual feed templates. It gets the title of the feed from the front matter of the feed template, and pulls in values from my global data file to populate the URLs, author name, and email. Then in ${data.content}, it pulls in the contents of the feed template, which would be the blog post entries created by the feedItems shortcode.

Before I end this post, I have to say writing all this out and trying to explain it highlighted issues and things I didn’t need, which is great. I should do this more often.