Pulling WordPress Post Categories & Tags Into Eleventy
TL;DR#anchor
This is another part in a series of posts about WordPress content being pulled into Eleventy, including:
- Composable Architecture Powered by WordPress (where it all started)
- Pulling WordPress Content into Eleventy
- Adding a Table of Contents to dynamic content in 11ty
This post is specifically expanding on the progress from the Pulling WordPress Content into Eleventy post to add categories and tags to my blog posts. I’m also going to add per category-filtered pages, and a per tags-filtered pages for good measure.
Here’s what it’s going to look like when it’s all done, or you can click around my blog to see it for yourself.
Why?#anchor
As I’ve been focusing more on blogging lately, I’ve amassed enough posts that I started realizing that it’s getting progressively harder to find a blog post the older it gets.
Really the only way to do so currently is to go to the main blog page and scroll or run a page search. Not ideal. I could add a site search but I’ve been dragging my heels on that too, so I’m opting for something a little easier for now.
Time to utilize the built in functionality of Categories and Tags from WordPress!
Adding Categories and Tags to Posts#anchor
First things first, we need to make sure to add some categories and tags to blog posts. I hadn’t up until now because I wasn’t using them, so I spent some time coming up with a list of categories and tags I’d like to use and then using “Quick Edit” to apply them to posts quickly. Check out this Categories and tags article for more details.
Once we have that data set up in WordPress, we need to make sure we’re gathering it in Eleventy when performing a build. We need to have a list of categories and tags attributed to the post in order to link to them in the blog post template.
These are collectively what WordPress refers to as terms
. The details (slug, title, etc.) of terms are not surfaced in the default REST API response, instead we’re only going to see references to their IDs in the main data object like this:
"categories": [7],
"tags": [34],
We’ll also see RESTful links inside _links
(which would return these details separately) like the following:
"wp:term": [
{
"taxonomy": "category",
"embeddable": true,
"href": "https://mysite.com/wp-json/wp/v2/categories?post=1"
},
{
"taxonomy": "post_tag",
"embeddable": true,
"href": "https://mysite.com/wp-json/wp/v2/tags?post=1"
}
],
Getting terms in the REST API response#anchor
Instead of performing multiple queries to get the category and tag data, we can add them to the embed section of the same query we’re already using.
As noted in the previous post about pulling content from WordPress, I’m splitting out the posts method getAllPosts from the post details method requestPosts.
To add the terms details, we add &_embed=wp:term
to the API request in requestPosts
. So in our previous code _embed: "wp:featuredmedia",
turns into _embed: "wp:featuredmedia,wp:term",
.
Next, we need to make sense of that data and add it to the blogpost data object we’re using to generate the blog pages.
Organizing the term data#anchor
WordPress doesn’t discern between a “category” and a “tag” in the embedded JSON, all terms are stored together with an associated taxonomy. Categories are in the category
taxonomy, and tags are in the post_tag
taxonomy.
For the same example as above where there’s one category whose ID is 7 and one tag with an ID of 34, here’s a slightly trimmed version of what that raw data ends up looking like:
"wp:term": [
[
{
"id": 7,
"link": "https://mysite.com/category/webdev/",
"name": "Web Dev",
"slug": "webdev",
"taxonomy": "category",
}
],
[
{
"id": 34,
"link": "https://mysite.com/tag/eleventy/",
"name": "eleventy",
"slug": "eleventy",
"taxonomy": "post_tag",
}
]
]
So, we’re going to need to separate categories and tags ourselves to be able to use them in those separate contexts. Here’s how I’m doing it.
Before
metaDescription: metaDescription,
slug: post.slug,
title: post.title.rendered,
After
metaDescription: metaDescription,
slug: post.slug,
title: post.title.rendered,
terms: post._embedded["wp:term"] ? post._embedded["wp:term"] : null,
categories: post.categories,
tags: post.tags,
categoriesDetail:
post._embedded["wp:term"]?.length > 0
? post._embedded["wp:term"].filter(
(term) => term[0]?.taxonomy == "category"
)[0]
: null,
tagsDetail:
post._embedded["wp:term"]?.length > 0
? post._embedded["wp:term"].filter(
(term) => term[0]?.taxonomy == "post_tag"
)[0]
: null,
It’s a little gnarly looking, but we don’t want our code to break if there aren’t any categories or tags defined so the checks are all squished in there too. If tags are found, I’m then filtering out categories and tags separately in the returned post data.
You’ll notice I’m also passing along the array of categories and tags IDs in categories
and tags
too, this is for more easily filtering blog posts by these terms in the individual category and tag pages.
Blog Post Updates#anchor
Now that I have post categories and tags, time to add them to the blog post pages!
Categories#anchor
I have “Published”, “Last Updated”, and reading time already listed at the top of the page just below the headline and above the blog contents. I want to add the category(ies) here too, so here’s the relevant Nunjucks template code for that addition:
{% if blogpost.categoriesDetail %}
<p>
<strong>Posted in </strong>
{% for category in blogpost.categoriesDetail %}
<a href="{{ '/blog/category/' + category.slug | url }}">{{ category.name }}</a>
{% if not loop.last %}, {% endif %}
{% endfor %}
</p>
{% endif %}
I’m checking for the presence of categories first, some of my posts aren’t categorized yet so I don’t want this to show at all for those. I then loop through the categories defined (because there can be multiple) and render a comma separated list.
Tags#anchor
The Nunjucks template code for tags is similar to the categories above, I ended up adding it to the top of the page as well but am considering moving to the bottom.
{% if blogpost.tagsDetail %}
<p>
<span aria-hidden="true" class="fe fe-tag"></span>
<strong>
{% if blogpost.tagsDetail.length > 1 %}Tags{% else %}Tag{% endif %}
</strong>:
{% for tag in blogpost.tagsDetail %}
<a href="{{ '/blog/tag/' + tag.slug | url }}">{{ tag.name }}</a>
{% if not loop.last %}, {% endif %}
{% endfor %}
</p>
{% endif %}
The biggest difference with this template snippet is that I’m pluralizing “Tags” instead of “Tag” if there are more than one.
New Pages#anchor
These additions are all looking great, but if you’re following along you may notice that the links I’ve added to the blog post template are all going to 404 pages. Of course, that’s because they don’t exist yet so let’s get on that.
Gathering Categories and Tags Data#anchor
I’ve not figured out a way to compile all the categories and tags into their own collections by using the data we’ve already gathered in blog posts. Instead, I had to run separate REST API calls for them. If you know of a better way to do this please do let me know!
In setting up these two new REST API calls, I noticed quite a bit of duplication between them, so I opted to do some cleanup and isolate the API calls and data manipulation needed for them all. I called it /utils/wp-json.js
. Here’s the code for that:
const { AssetCache } = require("@11ty/eleventy-fetch");
const axios = require("axios");
const jsdom = require("jsdom");
// Config
const ITEMS_PER_REQUEST = 10;
/**
* WordPress API call by page
*
* @param {Int} page - Page number to fetch, defaults to 1
* @return {Object} - Total, Pages, and full API data
*/
async function requestPage(apiBase, page = 1) {
try {
// https://developer.wordpress.org/rest-api/using-the-rest-api/pagination/
const url = apiBase;
const params = {
params: {
page: page,
per_page: ITEMS_PER_REQUEST,
_embed: "wp:featuredmedia,wp:term",
order: "desc",
},
};
const response = await axios.get(url, params);
return {
total: parseInt(response.headers["x-wp-total"], 10),
pages: parseInt(response.headers["x-wp-totalpages"], 10),
data: response.data,
};
} catch (err) {
console.error("API not responding, no data returned", err);
return {
total: 0,
pages: 0,
data: [],
};
}
}
/**
* Get all data from a WordPress API endpoint
* Use cached values if available, pull from API if not.
*
* @return {Array} - array of data objects
*/
async function getAllContent(API_BASE, ASSET_CACHENAME) {
const cache = new AssetCache(ASSET_CACHENAME);
let requests = [];
let apiData = [];
if (cache.isCacheValid("2h")) {
console.log("Using cached " + ASSET_CACHENAME);
return cache.getCachedValue();
}
// make first request and marge results with array
const request = await requestPage(API_BASE);
console.log(
"Using API " +
ASSET_CACHENAME +
", retrieving " +
request.pages +
" pages, " +
request.total +
" total records."
);
apiData.push(...request.data);
if (request.pages > 1) {
// create additional requests
for (let page = 2; page <= request.pages; page++) {
const request = requestPage(API_BASE, page);
requests.push(request);
}
// resolve all additional requests in parallel
const allResponses = await Promise.all(requests);
allResponses.map((response) => {
apiData.push(...response.data);
});
}
// return data
await cache.save(apiData, "json");
return apiData;
}
/**
* Clean up and convert the API response for our needs
*/
async function processContent(content) {
return Promise.all(
content.map(async (post) => {
// remove HTML-Tags from the excerpt for meta description
let metaDescription = post.excerpt.rendered.replace(/(<([^>]+)>)/gi, "");
metaDescription = metaDescription.replace("\n", "");
// Code highlighting with Eleventy Syntax Highlighting
// https://www.11ty.dev/docs/plugins/syntaxhighlight/
const formattedContent = highlightCode(prepared.content);
// Return only the data that is needed for the actual output
return await {
content: post.content.rendered,
formattedContent: formattedContent,
custom_fields: post.custom_fields ? post.custom_fields : null,
date: post.date,
dateRFC3339: new Date(post.date).toISOString(),
modifiedDate: post.modified,
modifiedDateRFC3339: new Date(post.modified).toISOString(),
excerpt: post.excerpt.rendered,
formattedDate: new Date(post.date).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
}),
formattedModifiedDate: new Date(post.modified).toLocaleDateString(
"en-US",
{
year: "numeric",
month: "long",
day: "numeric",
}
),
heroImageFull:
post._embedded["wp:featuredmedia"] &&
post._embedded["wp:featuredmedia"].length > 0
? post._embedded["wp:featuredmedia"][0].media_details.sizes.full
.source_url
: null,
heroImageThumb:
post._embedded["wp:featuredmedia"] &&
post._embedded["wp:featuredmedia"].length > 0
? post._embedded["wp:featuredmedia"][0].media_details.sizes
.medium_large
? post._embedded["wp:featuredmedia"][0].media_details.sizes
.medium_large.source_url
: post._embedded["wp:featuredmedia"][0].media_details.sizes.full
.source_url
: null,
metaDescription: metaDescription,
slug: post.slug,
title: post.title.rendered,
terms: post._embedded["wp:term"] ? post._embedded["wp:term"] : null,
categories: post.categories,
tags: post.tags,
categoriesDetail:
post._embedded["wp:term"]?.length > 0
? post._embedded["wp:term"].filter(
(term) => term[0]?.taxonomy == "category"
)[0]
: null,
tagsDetail:
post._embedded["wp:term"]?.length > 0
? post._embedded["wp:term"].filter(
(term) => term[0]?.taxonomy == "post_tag"
)[0]
: null,
};
})
);
}
function sortNameAlpha(content) {
return content.sort((a, b) => {
if (a.name < b.name) return -1;
else if (a.name > b.name) return 1;
else return 0;
});
}
module.exports = {
requestPage: requestPage,
getAllContent: getAllContent,
processContent: processContent,
sortNameAlpha: sortNameAlpha,
};
With this new set of utilities, the actual data JS files are super small. Here are the blogposts.js
, blogcategories.js
. and blogtags.js
files.
blogposts.js
const { getAllContent, processContent } = require("../utils/wp-json");
const API_BASE =
"https://mysite.com/wp-json/wp/v2/posts";
const ASSET_CACHENAME = "blogposts";
// export for 11ty
module.exports = async () => {
const blogposts = await getAllContent(API_BASE, ASSET_CACHENAME);
const processedPosts = await processContent(blogposts);
return processedPosts;
};
blogcategories.js
const { getAllContent, sortNameAlpha } = require("../utils/wp-json");
const API_BASE =
"https://mysite.com/wp-json/wp/v2/categories";
const ASSET_CACHENAME = "blogcategories";
// export for 11ty
module.exports = async () => {
const blogcategories = await getAllContent(API_BASE, ASSET_CACHENAME);
return sortNameAlpha(blogcategories);
};
blogtags
.js
const { getAllContent, sortNameAlpha } = require("../utils/wp-json");
const API_BASE =
"https://mysite.com/wp-json/wp/v2/tags";
const ASSET_CACHENAME = "blogtags";
// export for 11ty
module.exports = async () => {
const blogtags = await getAllContent(API_BASE, ASSET_CACHENAME);
return sortNameAlpha(blogtags);
};
Creating a Blog Post Term Filter#anchor
Great, we have data now! The next step is to set up a filter so we can show category and tag pages with just the posts that contain them. Because both are ID based, I opted to create one filter that’d work for either. Here’s the code
// Get the elements of a collection that contains the provided ID for the provided taxonomy
eleventyConfig.addFilter("blogTermFilter", (items, taxonomy, termID) => {
return items.filter((post) => {
return post[taxonomy].includes(termID);
});
});
Now I can pass the taxonomy (categories
or tags
) and its ID to get a filtered list of posts with that category or tag.
Creating the Category and Tag Pages#anchor
We’re getting really close now!
The final step is to create the tags and categories filtered pages. For me, they both have basically the same structure so I’m just going to share the categories one here.
---
layout: layouts/base.njk
pagination:
data: blogcategories
size: 1
alias: blogcategory
permalink: blog/category/{{ blogcategory.slug }}/
---
{% set blogslist = blogposts | blogTermFilter("categories", blogcategory.id) %}
<section class="l-container h-feed hfeed">
<header class="feature-list__header">
<h1 class="p-name">Blog Posts categorized under "{{ blogcategory.name }}"</h1>
<a href="{{ metadata.feed.path }}">RSS Feed</a>
</header>
<p>
<a href="{{ '/blog/' | url }}" class="btn btn--secondary btn--small">back to all blog posts</a>
</p>
<div class="grid-3-wide feature-list">
{% for post in blogslist %}
<div class="card z-depth-1 h-entry hentry">
<div class="img-16-9-aspect">
{%- if post.heroImageThumb %}
<img src="{{ post.heroImageThumb }}" alt="" loading="lazy">
{% else %}
<img src="/assets/images/posts/post-hero-placeholder.png" alt="" loading="lazy">
{% endif %}
</div>
<div class="card__content">
<{{itemheader}} class="headline4 p-name entry-title">
<a href="/blog/{{post.slug}}" class="u-url" rel="bookmark">
{% if post.title %}{{ post.title | safe }}
{% endif %}
</a>
</{{itemheader}}>
<div class="l-post__meta">
<p>
<strong>
<span aria-hidden="true" class="fe fe-calendar"></span>
<time class="postlist-date" datetime="{{ post.date }}">{{ post.formattedDate }}</time>
</strong>
</p>
</div>
<div class="p-summary entry-summary">
{%- if post.excerpt %}{{ post.excerpt | safe }}
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
</section>
You can copy this file and replace the references to categories over to tags instead for the Tags version. For example {% set blogslist = blogposts | blogTermFilter("tags",blogtag.id) %}
Potential Further Enhancements#anchor
This ended up being a bit more to figure out than I anticipated going into it, and I’m pretty happy with where it is now.
I do, however, have some ideas for future further enhancements:
- In addition to Previous and Next posts at the bottom of a blog post page, it’d be really great to have a “Related posts” section. That’s a fairly common feature of blogs and would help with discoverability.
- Advanced filtering from within the main Blog page would be nice, rather than just separate pages per category and tag. This would open up further options like filtering by category and tag together.
I may make time for these soon, let me know if you’d be interested in reading more about that!
Need help with an upcoming project?
I'd love to hear more about what you have coming up and how I can help bring it to life! My earliest availability is currently Q1 2025.
Get in Touch