blog.itcode.devblog.itcode.dev

[TypeScript] Building a Markdown TOC

One element of writing is the concept of a table of contents, also called a TOC (Table Of Content). Through a TOC, readers can grasp the overall content and structure of a piece of writing, and if they want, they can be guided to selectively pick just the parts they need. It's a useful device both for readers and for the writing itself.

[TypeScript] Building a Markdown TOC

One element of writing is the concept of a table of contents, also called a TOC (Table Of Content). Through a TOC, readers can grasp the overall content and structure of a piece of writing, and if they want, they can be guided to selectively pick just the parts they need. It's a useful device both for readers and for the writing itself.
RWB0104
@RWBwritten at 2023-09-25 16:53:00

One element of writing is the concept of a table of contents, also called a TOC (Table Of Content). Through a TOC, readers can grasp the overall content and structure of a piece of writing, and if they want, they can be guided to selectively pick just the parts they need. It's a useful device both for readers and for the writing itself.

Markdown uses the following syntax to express heading text like h1 and h2.

MARKDOWN

# h1

## h2

### h3

#### h4

##### h5

###### h6

# is used to express heading text, and h1 through h6 are expressed by the number of # characters. You can build a table of contents just by extracting this heading text.

As you can see on this blog, a TOC is provided. This TOC is rendered using a dependency called react-toc, which had the following issues.

  1. It returns a fully-built ul-based tag, which limits how much the layout can be changed.
  2. It even picks up # comments inside code blocks as TOC targets.
  3. Sometimes the TOC list stops being built partway through during the build process. It cuts off partway through for no apparent reason, and since it looks fine on the dev server, the issue seems to occur during the build.

So I decided to implement the TOC myself. The goals were as follows.

  1. Return an object containing the information needed to build the TOC.
  2. Exclude # comments inside code blocks.
  3. Eliminate the error that occurs during the build process.

I was able to solve this relatively simply using a regular expression.

Extracting only heading text from Markdown is very easy, because the rule is clear: it starts with 1 to 6 # characters, there's a space between the #s and the text, and the title text follows after the space.

Expressed as a regular expression, it looks like this.

REGEX

/^(#{1,6}) (.+)$/gm

It's a simple regex, but applying it as-is presents a tricky issue. That's code blocks — let's look at the examples below first.

MARKDOWN

# h1

## h2

### h3

#### h4

##### h5

###### h6

BASH

# comment
echo yahooo

There are languages that express Markdown inside code blocks, or use # as a comment symbol. The problem is that the regex ends up matching that text too. But it's a bit tricky to exclude this by adding conditions to the regex.

Thinking about it carefully, code blocks and the text inside them aren't actually needed at all for building the TOC list. Since code block syntax is also very distinctive, removing it via regex isn't difficult either.

The syntax starts and ends with ```. The regex for a code block is as follows.

REGEX

/```[^]*?```/gm

You can remove code blocks using this regex. After that, you just extract the TOC from the text with the code blocks removed.

Let's check this process in code. Suppose we have the following Markdown text.

MARKDOWN

# Lorem Ipsum

Lorem Ipsum is simply dummy text of the printing and typesetting industry.

Lorem Ipsum has been the industry’s standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book.

\`\`\` bash
# comments

echo Lorem Ipsum
\`\`\`

## Lorem Ipsum 2

It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged.

\`\`\` bash
# comments

echo Lorem Ipsum
\`\`\`

It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.

Since Markdown's code block syntax ` is hard to express inside a code block, it's replaced with \`. We store this in a variable called text, and use a regex to remove the code blocks.

TS

let text = `# Lorem Ipsum

Lorem Ipsum is simply dummy text of the printing and typesetting industry.

Lorem Ipsum has been the industry’s standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book.

\`\`\` bash
# comments

echo Lorem Ipsum
\`\`\`

## Lorem Ipsum 2

It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged.

\`\`\` bash
# comments

echo Lorem Ipsum
\`\`\`

It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.`

text = text.replace(/```[^]*?```/gm, '');

Now the variable text holds the Markdown text with the code blocks removed. Logging it to the console would look like this.

MARKDOWN

# Lorem Ipsum

Lorem Ipsum is simply dummy text of the printing and typesetting industry.

Lorem Ipsum has been the industry’s standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book.


## Lorem Ipsum 2

It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged.


It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.

Using this text and the regex, extracting the TOC produces the following.

TS


let text = '{...}';

let list = [];

const regex = /^(#{1,6}) (.+)$/gm;

while (flag)
{
    const match = regex.exec(temp);

    // When there's no matching regex
    if (match === null)
    {
        break;
    }

    list.push({
        level: match[1].trim().length,
        text: match[2].trim()
    });
}

Looking at the code above, you'll notice the regex is assigned to a variable called regex on purpose. This is because, in order to move from one match to the next, the regex object needs to remember the position it last checked. If you declare the regex inside the while loop, it gets reset on every iteration and loses its previous position. In that case, the loop would run forever.

Using a while loop, we run the loop until there's no more text matching the regex, extracting the heading text. The result is as follows.

JSON

[
    {
        "level": 1,
        "text": "Lorem Ipsum"
    },
    {
        "level": 2,
        "text": "Lorem Ipsum 2"
    },
]

The result above is just an example to show the logic's output — feel free to change the logic to return whatever format you need.

Combining the code above into a single method looks like this.

TS

export interface TocProps
{
    /**
     * Text
     */
    text: string;

    /**
     * Depth
     */
    level: number;
}

/**
 * Method to return a Markdown TOC list
 *
 * @param {string} text: text
 *
 * @returns {TocProps[]} Markdown TOC list
 */
export function getMarkdownToc(text: string): TocProps[]
{
    const list: TocProps[] = [];

    const temp = text.replace(/```[^]*?```/gm, '');

    const regex = /^(#{1,6}) (.+)$/gm;

    while (true)
    {
        const match = regex.exec(temp);

        // When there's no matching regex
        if (match === null)
        {
            break;
        }

        list.push({
            level: match[1].trim().length,
            text: match[2].trim()
        });
    }

    return list;
}

Passing Markdown text as a parameter to the above method returns an array of TOC objects.

You can build a TOC in whatever form you want based on this.

There were quite a few things I didn't like about the TOC section on the blog. Still, since it ran without major issues, I had left it alone, but for some reason I observed a problem where part of the TOC would be missing during the build process.

It seemed like there was an issue with how the library behaved during the build, so I just went ahead and built it myself, and I'm happy with how it turned out.

That's another thing I didn't like fixed.

# React# TypeScript# Markdown# Regexp# Table Of Content
ship
blog.itcode.dev

Notes from the π-th Alpaca

7.0.1
Developed by RWB since 2021.057th upgraded at 2026.08