While the Piano Pronto Blog isn’t primarily a tech blog, I still find it’s a good place for me to share some of the tech we use at Piano Pronto Publishing, Inc. In this article series, I aim to document the codebase that powers this blog, and some of the possibly unique things I’ve done with Astro framework. View the demo.
Table of Contents:
- Introduction
- Getting Unique Values
- Applying Filters
- Astro Routing Review
- Abstracting Routes
- Defining Differences
- Conclusion
💎 Introduction
If you read the previous article in the Steal This Blog! Series, you may recall the section about content helper functions. While I had expanded on my custom getCollection
function, I only briefly touched on the getTaxonomy
and getCollectionFilter
functions.
In this article, I will show on how I built custom filtering routes, such as example.com/tags/popular/
and example.com/author/houston/
. We will review how Astro uses file-based routing and create a reusable page component that handles filtering blog posts by any field.
🏷️ Getting Unique Values: Know What (Tags) You’re Dealing With
Before we can filter a content collection, we need to know what are the values we actually could filter by.
You’ll find the following function in src/content/utils.js
which loops through all items in a collection and collects their unique values for any given schema field, such as tags
or author
. I’ve added some comments below.
/**
* Returns all unique values from a collection for a given field
* @param {string} type - The type of collection such as "blog"
* @param {string} term - The field to get such as "tags" or "author"
* @returns {Array<Object>}
*/
export const getTaxonomy = async (type, term) => {
// Call our custom getCollection() function from src/content/utils.js
const items = await getCollection(type);
// Start reducing the array of blog posts into a Map where the value is the count
const taxonomy = items.reduce((acc, item) => {
// We want to support both array and reference fields, i.e. both:
// tags: ["tag 1", "tag 2"]
// author: "houston"
// So we're going to convert either of those into an array of values
const values = [];
if (Array.isArray(item.data[term])) {
values.push(...item.data[term]);
} else if (item.data[term]?.slug) {
values.push(item.data[term].slug);
}
// Loop through each value and increment the count by one
for (let value of values) {
const count = acc.get(value) || 0;
acc.set(value, count + 1);
}
// Return our accumulator
return acc;
}, new Map());
// On dev, make sure we have these tags even if there are no posts with them set
// This is so if we go to /tags/drafts/ when there are no draft posts, we won't 404
if (term === "tags" && import.meta.env.MODE == "development") {
if (!taxonomy.has("Drafts")) {
taxonomy.set("Drafts", 0);
}
if (!taxonomy.has("Scheduled")) {
taxonomy.set("Scheduled", 0);
}
}
// Convert the Map to an array of object and sort by count
return Array.from(taxonomy, ([value, count]) => ({ value, count })).sort(
(a, b) => b.count - a.count
);
};
Here’s an example of the function’s output, nifty huh?
getTaxonomy("blog", "tags");
[
{ "value": "Popular", "count": 3 },
{ "value": "Guides", "count": 2 },
{ "value": "Showcase", "count": 1 },
{ "value": "Drafts", "count": 0 },
{ "value": "Scheduled", "count": 0 }
]
🔎 Applying Filters: Refining the Results
Now that we know what we can filter by, we are ready to explore the getCollectionFilter()
function which (as the name implies) filters a collection. This function could be improved to let us define multiple criteria, but I haven’t had the need yet for this blog. I’ve again added some comments below to help explain what’s going on:
/**
* Returns collection entries matching a value for a given field
* @param {string} type - The type of collection such as "blog"
* @param {string} term - The field to search on such as "tags" or "author"
* @param {string} value - The value to match such as "Popular"
* @param {boolean} reverse - Sort with oldest posts first
* @returns {Array<Object>}
*/
export const getCollectionFilter = async (
type,
term,
value,
reverse = false
) => {
// Call our custom getCollection() function from src/content/utils.js
const items = await getCollection(type, true, reverse);
// Since value might be the human label, or from a URL, we will normalize it to a slug
const slug = slugify(value);
// Start filtering the content collection
return items.filter((item) => {
// See the comments in the getTaxonomy() function
const values = [];
if (Array.isArray(item.data[term])) {
values.push(...item.data[term]);
} else if (item.data[term]?.slug) {
values.push(item.data[term].slug);
}
// Does this collection entry contain the value we're looking for?
return values.map((value) => slugify(value)).includes(slug);
});
};
Here’s an example of the function’s output, with some of the fields truncated for brevity.
getCollectionFilter("blog", "tags", "popular");
[
// ...
{
"id": "second-post.md",
"slug": "second-post",
"body": "...",
"collection": "blog",
"data": {
"title": "Second post",
"description": "Lorem ipsum dolor sit amet",
"pubDate": "2022-07-15T07:00:00.000Z",
"updatedDate": null,
"heroImage": { ... },
"author": {
"slug": "houston",
"collection": "authors"
},
"tags": [ "Popular" ],
"featured": false,
"draft": false
}
},
// ...
]
⌚ Astro Routing Review: What’s in a (File) Name?
Now that we’ve built an API for filtering collections, we need to create the actual pages that will show filtered results on the frontend. Before we do that, we should review how Astro handles dynamic routes and nested pagination.
Let’s assume we’ve created a page component at src/pages/tags/[tag].astro
. We would then use the getStaticPaths()
function to tell Astro that we want to create a page at /tags/popular/
, /tags/guides/
, and at any other tags we’ve defined. Our file would look something like this:
---
import { getTaxonomy, getCollectionFilter } from "@/content/utils";
export async function getStaticPaths({ paginate, ...rest }) {
const routes = [];
const values = await getTaxonomy("blog", "tags");
for (const { value, count } of values) {
const posts = await getCollectionFilter("blog", "tags", value);
const route = paginate(posts, {
params: { tag: slugify(value) },
props: { title: value },
pageSize: consts.PAGE_SIZE,
});
routes.push(route);
}
return routes;
}
const { tag } = Astro.params;
const { page: pagination } = Astro.props;
const posts = pagination.data;
// ...
Here’s what we’re doing in the code above:
- Use our
getTaxonomy()
function to get an array of tags used in the blog content collection. - Loop through each tag and use our
getCollectionFilter()
function to get an array of blog posts matching that tag. - Use Astro’s built-in pagination helper to create a page showing the filtered posts.
- Load the tag and filtered posts in each individual route.
Note: Our file should have actually been at either src/pages/tags/[tag]/[page].astro
or src/pages/tags/[tag]/[...page].astro
in order to generate /tags/popular/1/
, /tags/popular/2/
, etc. See the API Reference for more details.
👓 Abstracting Routes: Taking a Step Back
The code in the previous section would work great if we only wanted to filter by tags. If we then decided we wanted to filter by author, we could just cp -R src/pages/tags src/pages/author
and find/replace, but we’d end up duplicating too much code. When you take a moment to think about how Astro’s dynamic routes work, you might know what we will do next.
We are going to abstract the page component and move it to src/pages/[term]/[value]/[...page].astro
. This will let us create a getStaticPaths()
function that loops through several fields, and loops through all values for those fields. Here’s what it looks like:
import { getTaxonomy, getCollectionFilter } from "@/content/utils";
export async function getStaticPaths({ paginate, ...rest }) {
const routes = [];
const terms = ["tags", "author"];
for (const term of terms) {
const values = await getTaxonomy("blog", term);
for (const { value, count } of values) {
const posts = await getCollectionFilter("blog", term, value);
const route = paginate(posts, {
params: { term, value: slugify(value) },
props: { title: value },
pageSize: consts.PAGE_SIZE,
});
routes.push(route);
}
}
return routes;
}
const { term, value } = Astro.params;
const { page: pagination } = Astro.props;
const posts = pagination.data;
// ...
The two main differences are that we need to set both term
and value
in the params
object, and then subsequently destructure them from Astro.props
. Notice how easy it would be to add even more terms to filter by, perhaps something like series
or category
.
❄️ Defining Differences: No Two Fields are Alike
Code abstraction is great, but what if there are some differences in how we want the tags and the author pages to look? Since we don’t have a huge number of filters yet, it’s not too much of a problem to just use some switch
or if
statements. If we did have more filters we might want to move logic somewhere else, but for now simple is fine.
First, let’s change some of the meta information depending on the term we’re displaying. I’ll explain how exactly the page
object affects meta tags in a future post, but for now just know that we want to pass it over to the layout/Base.astro
component.
let title = Astro.props.title,
description = null,
image = null;
switch (term) {
case "tags":
description = `Explore our blog for captivating articles tagged "${title}."`;
break;
case "author":
const author = await getAuthor(value);
title = author.data.name;
image = author.data.image;
description = `Explore our blog for articles posted by ${title}.`;
break;
}
const page = {
data: {
title:
title +
(pagination.currentPage > 1 ? ` | Page ${pagination.currentPage}` : ""),
description: description,
image: image ?? import(`../../../assets/meta/${term}.png`),
},
};
The other main difference is that on the author page, it would be nice to show the author’s bio. Fortunately, that’s pretty simple. Check out our template:
<BaseLayout layout="wide" page={page}>
<PostGrid
posts={posts}
pagination={pagination}
layout="normal"
baseUrl={`/${term}/${value}`}
>
<div class="prose col-span-full lg:prose-xl">
<h1>{title}</h1>
{term === "author" && <AboutAuthor author={value} title="" />}
<p>
A collection of {posts.length}
{posts.length == 1 ? "post" : "posts"}
</p>
</div>
</PostGrid>
</BaseLayout>
With just a single line, we can check if we’re displaying the author
term, and if so, include the partials/AboutAuthor.astro
component.
There’s one thing we’ve skipped over; how can readers see a list of all tags, authors, or whatever other term we want to encourage them to browse by? While we could most definitely create a page at src/pages/[term]/index.astro
that would list all terms with clickable links, I decided that I wanted the index page for each of the terms to look differently. For example, the browse by tags page just shows a list of tags, but the browse by author page might look much more complex with biographies, featured posts by those authors, etc.
As of now, I’ve only implemented src/pages/tag.astro
. It’s pretty simple, it just calls our getTaxonomy()
function and loops through each tag rendering the partials/Tag.astro
helper component. I’m sure I’ll be implementing a browse by author page soon.
💡 Conclusion
When I was evaluating a few different themes (before I decided to write my own,) many of the theme authors opted for the copy/paste approach under the pages
directory for the different filters. I felt that would cause more work on my end to customize each of those routes, and would lead to higher maintenance. In fact, after I had launched the blog, I decided I wanted a third filterable field series
(in fact this very article is in a series.) At that point, I was glad that I spent the time abstracting the code.
Hopefully this article is helpful to not only those using the Steal This Blog! theme, but for anyone else building a blog using Astro. I hope it can make you step back and think about what the getStaticPaths()
function is really capable of.
Be the first to comment.