Thoughts .toString()

Astro: Archives by year and month


Sorting and categorizing by years then months is useful over any collection of posts. The posts are usually displayed via an accordion by year, which expands to more fine-grained categories. Creating this should be straightforward for anyone who is used to using Javascript, but for those who only recently got their feet wet or haven’t used it in a while, like me, there are several useful things to note. We will be creating an Astro component for this, and routing will be done using getStaticPaths to keep the site static.

The routing

The general idea of getStaticPaths has been covered in a prior blog, but I will make a note of things to pay attention to using annotations in the code.

/src/pages/[year]/[month]/[...page].astroastro
1---
2import type { GetStaticPaths } from 'astro';
3import { type CollectionEntry, getCollection } from 'astro:content';
4
5type BlogPost = CollectionEntry<'blog'>;
6
7const sortByDateDesc = (a: BlogPost, b: BlogPost): number => {
8    return b.data.pubDate.valueOf() - a.data.pubDate.valueOf();
9};
10
11export const getStaticPaths = (async ({ paginate }) => {
12	const posts = (await getCollection('blog')).sort(sortByDateDesc);
We want a mapping of years and months to blog posts. However, objects can't easily be used as keys in JS objects without side effects. So the keys have to be joined into strings. We take advantage of the fact that Maps are sorted by insertion.
13    const dates = new Map<string, BlogPost[]>();
14    const dateKeys: string[] = [];
15
16    posts.forEach((post) => {
JS Date objects use zero-based months (0-11), so that must be corrected for.
17        const postYear = post.data.pubDate.getUTCFullYear();
18        const postMonth = post.data.pubDate.getUTCMonth() + 1;
19        const key = `${postMonth}/${postYear}`;
20        if (!dates.has(key)) {
21            dates.set(key, [post]);
22            dateKeys.push(key);
23        }
24        else {
25            dates.get(key)?.push(post);
26        }
27    });
28
29    return dateKeys.flatMap((monthYear) => {
30        const [month, year] = monthYear.split('/');
31        const filteredPosts = dates.get(monthYear)!;
32        return paginate(filteredPosts, {
33            params: { year, month },
34            pageSize: 10
35        });
36    });
37}) satisfies GetStaticPaths;
38
39type Params = { year: string, month: string };
40const { year, month } = Astro.params as Params;
41const { page } = Astro.props;
42---

The component

Anyways, the layout of the page isn’t so interesting. What’s important is that we now have a route for each year and month that there are posts. Next we apply a similar technique to get the component to list the categories.

/src/components/Archive.astroastro
1---
2import { getCollection } from 'astro:content';
3import { sortByDateDesc } from '../helpers';
4
5const posts = (await getCollection('blog')).sort(sortByDateDesc);
This time, years are mapped to months to get the correct routes. We use a Set for months to automatically ignore duplicates. Again, we leverage that the Set in JS is guaranteed to be ordered by insertion, unlike in e.g. Python.
6type PostsMap = Map<number, Set<number>>;
7const postsByYear: PostsMap = new Map();
8posts.forEach((post) => {
The month is kept zero-based to make it easier to index month names to display.
9    const postMonth = post.data.pubDate.getUTCMonth();
10    const postYear = post.data.pubDate.getUTCFullYear();
11    if (!postsByYear.has(postYear)) {
12        postsByYear.set(postYear, new Set([postMonth]));
13    }
14    else {
15        postsByYear.get(postYear)?.add(postMonth);
16    }
17});
18
19const years: number[] = Array.from(postsByYear.keys());
20
21const monthNames = [
22    'January', 'February', 'March', 'April', 'May', 'June', 'July', 
23    'August', 'September', 'October', 'November', 'December'
24];
This is used to add an attribute to the HTML <details> tag so that the latest category is open by default. The relevant part is the name of the key, the value can be any truthy value.
25const open = true;
26---
27<section>
28    {
29        years.map((year, index) => (
Only the most recent year is open by default, since the routes are sorted by descending date.
30            <details open={ index === 0 && open }>
31                <summary>{year}</summary>
32                <ul>
33                    {
34                        Array.from(postsByYear.get(year)!).map((month) => (
35                            <li>
36                                <a href={`/blog/${year}/${month + 1}`}>
37                                    {monthNames[month]}, {year}
38                                </a>
39                            </li>
40                        ))
41                    }
42                </ul>
43            </details>
44        ))
45    }
46</section>