Software Tech

Pulling WordPress Post Categories & Tags Into Eleventy

TL;DR This is another part in a series of posts about WordPress content being pulled into Eleventy, including: Composable

Pulling WordPress Post Categories & Tags Into Eleventy

TL;DR

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 is specifically expanding on the progress 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.


Category Link List


Posts filtered by Category


Categories and Tags listed at the top of a post

Why?

As I've been focusing more on 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

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 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

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

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. 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”] : ,
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 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

Now that I have post categories and tags, time to add them to the blog post pages!

Categories

I have “Published”, “Last Updated”, and 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 %}

Posted in
{% for category in blogpost.categoriesDetail %}

{% if not loop.last %}, {% endif %}
{% endfor %}

{% 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

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 %}



{% if blogpost.tagsDetail.length > 1 %}Tags{% else %}Tag{% endif %}

{% for tag in blogpost.tagsDetail %}

{% if not loop.last %}, {% endif %}
{% endfor %}

{% 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

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

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.(“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

const request = requestPage(API_BASE, page);
requests.push(request);
}

// all additional requests in parallel
const allResponses = await Promise.all(requests);
allResponses.((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

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) => {

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

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

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) %}









{% for post in blogslist %}


{%- if post.heroImageThumb %}

{% else %}

{% endif %}




{% if post.title %}{{ post.title | safe }}
{% endif %}











{%- if post.excerpt %}{{ post.excerpt | safe }}
{% endif %}



{% endfor %}

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

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 .

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!

About Author

Steven Woodson

Leave a Reply

SOFAIO BLOG We would like to show you notifications for the latest news and updates.
Dismiss
Allow Notifications