Pulling WordPress Post Categories & Tags Into Eleventy

stevenwoodson

Steven Woodson

Posted on August 15, 2023

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:

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.


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

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],
Enter fullscreen mode Exit fullscreen mode

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"
  }
],
Enter fullscreen mode Exit fullscreen mode

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",
    }
  ]
]
Enter fullscreen mode Exit fullscreen mode

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,
Enter fullscreen mode Exit fullscreen mode

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,
Enter fullscreen mode Exit fullscreen mode

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

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

Categories

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 %}
Enter fullscreen mode Exit fullscreen mode

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 %}
  <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 %}
Enter fullscreen mode Exit fullscreen mode

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.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,
};
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode

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);
    });
  });
Enter fullscreen mode Exit fullscreen mode

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

<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>
Enter fullscreen mode Exit fullscreen mode

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

💖 💪 🙅 🚩
stevenwoodson
Steven Woodson

Posted on August 15, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related