Recently, inspired in part by a conversation Claudia Snell was having with folks in the Frontend Horse Discord server, I set up my blogroll, a list of some blogs I read regularly that I would recommend folks check out.
At its heart, all a blogroll needs to be is a static list of links to other blogs. No further complexity is required beyond that point. You could absolutely hardcode such a thing directly in HTML. However, I wanted to try my hand at a more complicated design, featuring each site's favicon and a link to their latest post.
What follows is how I built the finished product. My site is built with Eleventy, so this approach will definitely be Eleventy-flavored, but I'm sure bits and pieces of this approach could be adapted to any build tool or framework.
Ensuring the Latest Posts Stay Somewhat Up to Date
Eventually, we'll be fetching RSS feeds and displaying each blog's latest posts, but well before that point, we'll need an approach to ensure those posts stay roughly up to date.
We have a few possible approaches we could take to ensure we're showing the latest, greatest, up-to-datest posts:
- Do nothing special; let each rebuild update the posts. If you rebuild your site frequently, this will work well enough. If your site rebuilds less frequently, the "latest" posts will remain very stale for a long time.
- Schedule regular rebuilds. The blogroll page is still static and pregenerated, but automatically updating it, say, every day or so will make sure the latest posts never fall too far behind.
- Build the blogroll on each request. For an Eleventy site, this would entail using either Eleventy Serverless or Eleventy Edge to fetch each blog's RSS feed and build the blogroll page anew whenever a user hits the page.
- Fetch RSS feeds with client-side scripts. This involves pushing a bunch of scripts to users, delays the latest-post information rendering, and will likely introduce the need to handle loading and error states.
In my case, my site was already set up to rebuild daily, something I'd put together in my Some Antics days to auto-update the YouTube thumbnail on the homepage without serving up YouTube's heavyweight scripts to every user. I use a GitHub Action to schedule my daily Netlify builds; your stack may have a different approach for scheduling regular rebuilds.
The regular-rebuilds technique won't be truly, immediately up-to-date, but for my needs, it's up-to-date enough — especially given that this is the most robust and performant experience for the end user, who as far as their browser is concerned, is just pulling a standard-issue, prebuilt HTML file living on a server.
Setting Up the Basic Blog Data
Next up, I opted to store a list of the blogs in Eleventy data. If I were going for a simple list of links to blogs with nothing added, I'd push for just hardcoding the markup directly, but since I planned to use this information to orchestrate fetching further information, I needed the blog list stored in data.
This data is only used on one page, the blogroll page, which means the blog list could sit pretty much anywhere in Eleventy's data cascade. I opted to set it up as directory data so I could colocate the data with the upcoming template file, but there's no reason I couldn't have set it up as global data instead.
I created a src/blogroll/
directory in my project, and in there, I created a JSON file called blogroll.11tydata.json
. The fact that the blogroll
in the filename matches the directory name establishes this file within Eleventy as a directory data file.
Then I populated that JSON file:
src/blogroll/blogroll.11tydata.json
{
"blogs": [
{
"name": "Adrian Roselli",
"url": "https://adrianroselli.com/",
"feed": "https://adrianroselli.com/feed"
},
{
"name": "Ashlee M. Boyer",
"url": "https://ashleemboyer.com/",
"feed": "https://ashleemboyer.com/rss.xml"
},
{
"name": "Baldur Bjarnason",
"url": "https://www.baldurbjarnason.com/",
"feed": "https://www.baldurbjarnason.com/index.xml"
},
// ...
]
}
Now, any template within src/blogroll/
will be able to access this list using the blogs
data variable.
The details I've provided about each blog here are minimal, only the stuff I'm fine hardcoding. For your blogroll, you might opt to hardcode more details into this list — maybe a short description of each blog?
Fetching the Latest Posts
Now that we have the basic scaffold of information for each blog in our blogroll, we want to fetch each blog's latest post and surface it within our Eleventy data.
Anytime we want to take some data that already exists (like our raw blog list) and act on it to expose some new data (like latest posts), Eleventy's computed data feature comes in handy.
Here, I needed two libraries: rss-parser
, which does what it says on the tin, and Eleventy Fetch, which handles caching fetched results for us whenever we run the dev server locally.
npm install --save rss-parser @11ty/eleventy-fetch
Then in src/blogroll/
, I created a JavaScript directory data file called blogroll.11tydata.js
. That data file looked like this:
src/blogroll/11tydata.js
const {AssetCache} = require('@11ty/eleventy-fetch');
const RssParser = require('rss-parser');
const rssParser = new RssParser({timeout: 5000});
/** Sorter function for an array of feed items with dates */
function sortByDateDescending(feedItemA, feedItemB) {
const itemADate = new Date(feedItemA.isoDate);
const itemBDate = new Date(feedItemB.isoDate);
return itemBDate - itemADate;
}
/** Fetch RSS feed at a given URL and return its latest post (or get it from cache, if possible) */
async function getLatestPost(feedUrl) {
const asset = new AssetCache(feedUrl);
// If cache exists, happy day! Use that.
if (asset.isCacheValid('1d')) {
const cachedValue = await asset.getCachedValue();
return cachedValue;
}
const rssPost = await rssParser
.parseURL(feedUrl)
.catch((err) => {
console.error(feedUrl, err);
return null;
})
.then((feed) => {
if (!feed || !feed.items || !feed.items.length) {
return null;
}
const [latest] = [...feed.items].sort(sortByDateDescending);
if (!latest.title || !latest.link) {
return null;
}
return {title: latest.title, url: latest.link};
});
await asset.save(rssPost, 'json');
return rssPost;
}
module.exports = {
eleventyComputed: {
/** Augments blog info with fetched information from the actual blogs */
async blogData({blogs}) {
const augmentedBlogInfo = await Promise.all(blogs.map(async (rawBlogInfo) => {
return {
...rawBlogInfo,
latestPost: rawBlogInfo.feed ?
await getLatestPost(rawBlogInfo.feed) :
null
};
}));
return augmentedBlogInfo;
}
}
};
Whew. There's a lot here. The long and short of this is that in addition to our scaffoldlike blogs
data array, we now also have a blogData
data array which looks similar, but wherein each blog object might also have a latestPost
property. Additionally, each fetch response is stored in a cache (via Eleventy Fetch's AssetCache
) that persists for one day, which will ensure that our local development server doesn't fire off spurious requests to each of these feeds every time we make a change to our site, so that our dev server isn't bogged down and we're not flooding our favorite blogs with swarms of unnecessary requests.
We'll add more to our augmented blog data objects shortly, but first, let's put some contents on an HTML page, just to make sure everything's working so far.
The Blogroll Page Template
Next up, I created a template file for the blogroll page. In practice, this template made use of some base layouts I already had, but I'll omit that setup for the purpose of this blog.
src/blogroll/index.njk
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Blogroll</title>
</head>
<body>
<main>
<div class="blogroll-grid">
{% for blog in blogData %}
<article class="blogroll-card">
<p class="blog-title">
<a href="{{ blog.url }}">
{{ blog.name }}
</a>
</p>
{% if blog.latestPost and blog.latestPost.title %}
<p class="latest-post">
<b>Latest post:</b>
<a href="{{ blog.latestPost.url }}">
{{ blog.latestPost.title }}
</a>
</p>
{% endif %}
</article>
{% endfor %}
</div>
</main>
</body>
</html>
This template iterates through our augmented blogData
data array, and outputs an <article>
for each entry. Each <article>
contains a link to the blog and, if a latest post was found successfully, a link to that post.
If you run your project, you should now see this new template at /blogroll/
. Each blog should be listed out alongside its latest post.
At this point, you can begin styling the page, but I had a few more enhancements I wanted to make.
Showing Each Blog's Favicon
Favicons are a lovely expression of each site's personality, and showing them would go a long way towards giving each blog entry its own flair.
I explored a few options for getting a site's favicons, but at the end of the day, the simplest option by far came from URL-based favicon fetching API services, including one from Eleventy project itself: Zach's IndieWeb Avatar service. Given any site URL, you can get its favicon as a valid <img>
src
with just a quick transformation:
const encodedBlogUrl = encodeURIComponent(blog.url);
const src = `https://v1.indieweb-avatar.11ty.dev/${encodedBlogUrl}/`;
Easy enough!
This transformation from blog URL to its IndieWeb Avatar service URL could be achieved perfectly fine with Eleventy filters in your templates, but since I already had the computed data infrastructure, I opted to keep adding to it instead:
src/blogroll/blogroll.11tydata.js
// … all your imports, getLatestPost, etc.
module.exports = {
eleventyComputed: {
/** Augments blog info with fetched information from the actual blogs */
async blogData({blogs}) {
const augmentedBlogInfo = await Promise.all(blogs.map(async (rawBlogInfo) => {
const encodedUri = encodeURIComponent(rawBlogInfo.url);
const favicon = `https://v1.indieweb-avatar.11ty.dev/${encodedUri}/`;
return {
...rawBlogInfo,
favicon,
latestPost: rawBlogInfo.feed ?
await getLatestPost(rawBlogInfo.feed) :
null
};
}));
return augmentedBlogInfo;
}
}
};
Then in our templates:
src/blogroll/index.njk
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Blogroll</title>
</head>
<body>
<main>
<div class="blogroll-grid">
{% for blog in blogData %}
<article class="blogroll-card">
<p class="blog-title">
<img alt="" src="{{ blog.favicon }}" width="16px" height="16px" loading="lazy" decoding="async" />
<a href="{{ blog.url }}">
{{ blog.name }}
</a>
</p>
{% if blog.latestPost and blog.latestPost.title %}
<p class="latest-post">
<b>Latest post:</b>
<a href="{{ blog.latestPost.url }}">
{{ blog.latestPost.title }}
</a>
</p>
{% endif %}
</article>
{% endfor %}
</div>
</main>
</body>
</html>
Now, our blogroll shows some handy favicons, adding a pop of color and variety to each blog in the list!
It's worth mentioning that if the IndieWeb Avatar service fails to get the blog's avatar, it'll use a fallback image of the Eleventy logo. This may or may not be your speed.
There are a number of other similar avatar services you could use instead, if you like, including those made by Google and DuckDuckGo. These all follow similar URL-based approaches. For more info, I recommend Jim Nielsen's post on displaying favicons.
Displaying Pretty URLs
For my blogroll design, I wanted to show a cleaned up version of each blog's URL. I did this with the normalize-url
package, which I'd previously used on the Some Antics site to format guests' personal sites.
We'll first install normalize-url
. In v7 of normalize-url
, the package went ESM-only. Since, at time of writing, Eleventy doesn't yet support ESM, we'll need to install version 6 or lower. Other frameworks or build tools likely won't have that stipulation.
npm install --save normalize-url@6
Like the avatar, we could probably set up a filter to handle this transformation for us, but since I already had the computed data approach set up to augment the data for each blog in the list, I just kept using that.
src/blogroll/blogroll.11tydata.js
const normalizeUrl = require('normalize-url');
// … all of your other imports, getLatestPost, etc.
module.exports = {
eleventyComputed: {
/** Augments blog info with fetched information from the actual blogs */
async blogData({blogs}) {
const augmentedBlogInfo = await Promise.all(blogs.map(async (rawBlogInfo) => {
const encodedUri = encodeURIComponent(rawBlogInfo.url);
const favicon = `https://v1.indieweb-avatar.11ty.dev/${encodedUri}/`;
return {
...rawBlogInfo,
cleansedUrl: normalizeUrl(
rawBlogInfo.url,
{stripProtocol: true}
),
favicon,
latestPost: rawBlogInfo.feed ?
await getLatestPost(rawBlogInfo.feed) :
null
};
}));
return augmentedBlogInfo;
}
}
};
And then we add this new data to the page in our template:
src/blogroll/index.njk
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Blogroll</title>
</head>
<body>
<main>
<div class="blogroll-grid">
{% for blog in blogData %}
<article class="blogroll-card">
<p class="blog-title">
<img alt="" src="{{ blog.favicon }}" width="16px" height="16px" loading="lazy" decoding="async" />
<a href="{{ blog.url }}">
{{ blog.name }}
</a>
</p>
<p class="blog-url">{{ blog.cleansedUrl }}</p>
{% if blog.latestPost and blog.latestPost.title %}
<p class="latest-post">
<b>Latest post:</b>
<a href="{{ blog.latestPost.url }}">
{{ blog.latestPost.title }}
</a>
</p>
{% endif %}
</article>
{% endfor %}
</div>
</main>
</body>
</html>
Voilà
With that, you have the setup for a blogroll that updates daily for any Eleventy site. If you haven't already, now's the time to add your styles.
Other enhancements could absolutely be added to the blogroll — showing the blogs' latest posts' dates or their total post counts comes to mind, as does adding brief descriptions of each blog — but I was pretty happy with this state so far.
What does your blogroll look like?
Have you added anything exciting to yours? Let me know on Mastodon!