This article is the English translation of the Japanese article titled ‘Astroで作ったブログ記事に目次をつける方法’.

Adding a Table of Contents to an Astro Blog

In addition to this blog, I also run a blog called Kuro’s Thinking Notes, where I document my experiment of using Linux as my daily operating system. Recently, I migrated that site from WordPress to Astro. While trying to add a table of contents to my articles, I ran into a few challenges, so I decided to write about the process.

The HTML Structure We Want to Generate

The ultimate goal is to dynamically generate HTML with nested H2 and H3 headings like the following:

<ol>
  <li>H2 Heading 1</li>
  <li>H2 Heading 2
    <ul>
      <li>H3 Heading 1</li>
      <li>H3 Heading 2</li>
      <li>H3 Heading 3</li>
    </ul>
  </li>
  <li>H2 Heading 3
    <ul>
      <li>H3 Heading 1</li>
      <li>H3 Heading 2</li>
    </ul>
  </li>
  <li>H2 Heading 4</li>
</ol>

Retrieving Heading Information from Astro.props

If your content is managed in Markdown, you can obtain heading information from Astro.props.headings. The data is provided in the following format:

Example Data

[
  { depth: 2, slug: "prerequisites", text: "Prerequisites" },
  {
    depth: 2,
    slug: "installing-fcitx-and-fcitx-mozc",
    text: "Installing Fcitx and Fcitx-mozc"
  },
  { depth: 2, slug: "setting-environment-variables", text: "Setting Environment Variables" },
  { depth: 2, slug: "starting-fcitx", text: "Starting Fcitx" },
  { depth: 2, slug: "configuration", text: "Configuration" },
  { depth: 3, slug: "input-method-engine", text: "Input Method Engine" },
  { depth: 3, slug: "changing-fcitx-keymap", text: "Changing the Fcitx Keymap" },
  { depth: 3, slug: "changing-mozc-keymap", text: "Changing the Mozc Keymap" },
  { depth: 2, slug: "summary", text: "Summary" }
]

This data is enough to generate a table of contents. However, as you can see, the heading level is represented by the numeric depth property, and H2 and H3 headings are mixed together in a flat array, making it somewhat inconvenient to work with directly.

Transforming the Data

As mentioned above, the data returned by Astro.props.headings is not ideal for generating nested HTML, so we need to transform it. We also need to handle two cases:

  1. Articles containing both H2 and H3 headings
  2. Articles containing only H3 headings

The following JavaScript code performs the transformation.

src/layouts/page.astro

function createIndex() {
  let index = []
  const depth = checkDepth();
  for (const item of headings) {
    if (depth === 2) {
      if (item.depth === 2) {
        index.push({
          slug: item.slug,
          text: item.text,
          child: []
        });
      } else if (item.depth === 3) {
        index[(index.length - 1)].child.push({
          slug: item.slug,
          text: item.text,
          chilid: []
        });
      }
    } else if (depth === 3) {
      if (item.depth === 3) {
        index.push({
          slug: item.slug,
          text: item.text,
          child: []
        })
      }
    }
  }
  return index
}

function checkDepth() {
  for (const item of headings) {
    if (item.depth === 2) {
      return 2;
    }

    return 3;
  }
}

Generating Nested HTML

To generate nested HTML dynamically in Astro, I found that the child HTML needs to be implemented as a separate component.

First, create the child component.

components/index-child.astro

const {children} = Astro.props;

{children.length > 0 && <ul>{children.map(item => <li><a href={`#${item.slug}`}>{item.text}</a></li>)}</ul>}

Next, add the table-of-contents generation code to the layout file used for your blog posts. In this example, the file is layouts/post.astro.

src/layouts/post.astro

...
import IndexChild from '../components/index-child.astro';
...
    <div class="index-wrapper">
      <input id="index-button" type="checkbox" checked />
      <div class="index-header">Table of Contents [<label class="label" for="index-button"></label>]</div>
      <div class="index-content">
        <ol>
          {createIndex().map(item => <li><a href={`#${item.slug}`}>{item.text}</a><IndexChild children={item.child} /></li>)}
        </ol>
      </div>
    </div>
...

The key point here is passing child elements to the child component when an H2 heading contains H3 headings as nested items.

Initially, I tried generating the entire HTML structure without using a child component, but at the time of writing, that did not seem possible in Astro.

The resulting HTML looks like this:

Generated HTML Example

<div class="index-content">
  <ol>
    <li><a href="#prerequisites">Prerequisites</a></li>
    <li><a href="#installing-fcitx-and-fcitx-mozc">Installing Fcitx and Fcitx-mozc</a></li>
    <li><a href="#setting-environment-variables">Setting Environment Variables</a></li>
    <li><a href="#starting-fcitx">Starting Fcitx</a></li>
    <li><a href="#configuration">Configuration</a>
      <ul>
        <li><a href="#input-method-engine">Input Method Engine</a></li>
        <li><a href="#changing-fcitx-keymap">Changing the Fcitx Keymap</a></li>
        <li><a href="#changing-mozc-keymap">Changing the Mozc Keymap</a></li>
      </ul>
    </li>
    <li><a href="#summary">Summary</a></li>
  </ol>
</div>

Conclusion

I decided to write this article because dynamically generating nested HTML turned out to be a stumbling block for me.

Astro is an excellent static site generator, but generating a table of contents felt a little cumbersome. If you know of a cleaner or more convenient approach, I’d love to hear about it.