Saturday, 1 May 2021

Blog Archive for Docusaurus

Docusaurus doesn't ship with "blog archive" functionality. By which I mean, something that allows you to look at an overview of your historic blog posts. It turns out it is fairly straightforward to implement your own. This post does just that.

Docusaurus blog archive

Update 2021-09-01

As of v2.0.0-beta.6, Docusauras does ship with blog archive functionality that lives at the archive route. This is down to the work of Gabriel Csapo in this PR.

If you'd like to know how to build your own, read on… But you may not need to!

Blogger's blog archive

I recently went through the exercise of migrating my blog from Blogger to Docusaurus. I found that Docusaurus was a tremendous platform upon which to build a blog, but it was missing a feature from Blogger that I valued highly; the blog archive:

Blogger blog archive

The blog archive is a way by which you can browse through your historic blog posts. A place where you can see all that you've written and when. I find this very helpful. I didn't really want to make the jump without having something like that around.

Handrolling a Docusaurus blog archive

Let's create our own blog archive in the land of the Docusaurus.

We'll create a new page under the pages directory called blog-archive.js and we'll add a link to it in our docusaurus.config.js:

    navbar: {
      // ...
      items: [
        // ...
        { to: "blog-archive", label: "Blog Archive", position: "left" },
        // ...
      ],
    },

Obtaining the blog data

This page will be powered by webpack's require.context function. require.context allows us to use webpack to obtain all of the blog modules:

require.context('../../blog', false, /.md/);

The code snippet above looks in the blog directory for files / modules ending with the suffix ".md". Each one of these represents a blog post. The function returns a context object, which contains all of the data about these modules.

By reducing over that data we can construct an array of objects called allPosts that could drive a blog archive screen. Let's do this below, and we'll use TypeScripts JSDoc support to type our JavaScript:

/**
 * @typedef {Object} BlogPost - creates a new type named 'BlogPost'
 * @property {string} date - eg "2021-04-24T00:00:00.000Z"
 * @property {string} formattedDate - eg "April 24, 2021"
 * @property {string} title - eg "The Service Now API and TypeScript Conditional Types"
 * @property {string} permalink - eg "/2021/04/24/service-now-api-and-typescript-conditional-types"
 */

/** @type {BlogPost[]} */
const allPosts = ((ctx) => {
  /** @type {string[]} */
  const blogpostNames = ctx.keys();

  return blogpostNames.reduce(
    (blogposts, blogpostName, i) => {
      const module = ctx(blogpostName);
      const { date, formattedDate, title, permalink } = module.metadata;
      return [
        ...blogposts,
        {
          date,
          formattedDate,
          title,
          permalink,
        },
      ];
    },
    /** @type {string[]}>} */ []
  );
})(require.context('../../blog', false, /.md/));

Observe the metadata property in the screenshot below:

require.context

This gives us a flavour of the data available in the modules and shows how we pull out the bits that we need; date, formattedDate, title and permalink.

Presenting it

Now we have our data in the form of allPosts, let's display it. We'd like to break it up into posts by year, which we can do by reducing and looking at the date property which is an ISO-8601 style date string taking a format that begins yyyy-mm-dd:

const postsByYear = allPosts.reduceRight((posts, post) => {
  const year = post.date.split('-')[0];
  const yearPosts = posts.get(year) || [];
  return posts.set(year, [post, ...yearPosts]);
}, /** @type {Map<string, BlogPost[]>}>} */ new Map());

const yearsOfPosts = Array.from(postsByYear, ([year, posts]) => ({
  year,
  posts,
}));

Now we're ready to blast it onto the screen. We'll create two components:

  • Year - which is a list of the posts for a given year and
  • BlogArchive - which is the overall page and maps over yearsOfPosts to render Years
function Year(
  /** @type {{ year: string; posts: BlogPost[]}} */ { year, posts }
) {
  return (
    <div className={clsx('col col--4', styles.feature)}>
      <h3>{year}</h3>
      <ul>
        {posts.map((post) => (
          <li key={post.date}>
            <Link to={post.permalink}>
              {post.formattedDate} - {post.title}
            </Link>
          </li>
        ))}
      </ul>
    </div>
  );
}

function BlogArchive() {
  return (
    <Layout title="Blog Archive">
      <header className={clsx('hero hero--primary', styles.heroBanner)}>
        <div className="container">
          <h1 className="hero__title">Blog Archive</h1>
          <p className="hero__subtitle">Historic posts</p>
        </div>
      </header>
      <main>
        {yearsOfPosts && yearsOfPosts.length > 0 && (
          <section className={styles.features}>
            <div className="container">
              <div className="row">
                {yearsOfPosts.map((props, idx) => (
                  <Year key={idx} {...props} />
                ))}
              </div>
            </div>
          </section>
        )}
      </main>
    </Layout>
  );
}

Bringing it all together

We're finished! We have a delightful looking blog archive plumbed into our blog:

Docusaurus blog archive

It is possible that a blog archive may become natively available in Docusaurus in future. If you're interested in this, you can track this issue.

Here's the final code - which you can see powering this screen. And you can see the code that backs it here:

import React from 'react';
import clsx from 'clsx';
import Layout from '@theme/Layout';
import Link from '@docusaurus/Link';
import styles from './styles.module.css';

/**
 * @typedef {Object} BlogPost - creates a new type named 'BlogPost'
 * @property {string} date - eg "2021-04-24T00:00:00.000Z"
 * @property {string} formattedDate - eg "April 24, 2021"
 * @property {string} title - eg "The Service Now API and TypeScript Conditional Types"
 * @property {string} permalink - eg "/2021/04/24/service-now-api-and-typescript-conditional-types"
 */

/** @type {BlogPost[]} */
const allPosts = ((ctx) => {
  /** @type {string[]} */
  const blogpostNames = ctx.keys();

  return blogpostNames.reduce(
    (blogposts, blogpostName, i) => {
      const module = ctx(blogpostName);
      const { date, formattedDate, title, permalink } = module.metadata;
      return [
        ...blogposts,
        {
          date,
          formattedDate,
          title,
          permalink,
        },
      ];
    },
    /** @type {string[]}>} */ []
  );
})(require.context('../../blog', false, /.md/));

const postsByYear = allPosts.reduceRight((posts, post) => {
  const year = post.date.split('-')[0];
  const yearPosts = posts.get(year) || [];
  return posts.set(year, [post, ...yearPosts]);
}, /** @type {Map<string, BlogPost[]>}>} */ new Map());

const yearsOfPosts = Array.from(postsByYear, ([year, posts]) => ({
  year,
  posts,
}));

function Year(
  /** @type {{ year: string; posts: BlogPost[]}} */ { year, posts }
) {
  return (
    <div className={clsx('col col--4', styles.feature)}>
      <h3>{year}</h3>
      <ul>
        {posts.map((post) => (
          <li key={post.date}>
            <Link to={post.permalink}>
              {post.formattedDate} - {post.title}
            </Link>
          </li>
        ))}
      </ul>
    </div>
  );
}

function BlogArchive() {
  return (
    <Layout title="Blog Archive">
      <header className={clsx('hero hero--primary', styles.heroBanner)}>
        <div className="container">
          <h1 className="hero__title">Blog Archive</h1>
          <p className="hero__subtitle">Historic posts</p>
        </div>
      </header>
      <main>
        {yearsOfPosts && yearsOfPosts.length > 0 && (
          <section className={styles.features}>
            <div className="container">
              <div className="row">
                {yearsOfPosts.map((props, idx) => (
                  <Year key={idx} {...props} />
                ))}
              </div>
            </div>
          </section>
        )}
      </main>
    </Layout>
  );
}

export default BlogArchive;