このブログのことではないのですが、markdown-itを使ってMarkdownで書いた記事を表示する際に、目次(Table of Contents, TOC)を記事の外に表示したいと思いました。記事の外とは、例えばサイドバーです。

markdown-itで目次を記事中に表示するプラグインはいくつかあるようですが、記事外に表示することはプラグインでは達成できないようです。自分でTypeScriptを書いて実現しました。

完成したプログラム

markdown-it demoの範囲で期待通りに動作することを確認しています。

Markdownから目次を生成する部分

markdown-it-anchorが読み込まれている前提です。

import MarkdownIt from 'markdown-it'

export interface HeadingListItem {
  level: number
  id: string
  text: string
}

export const renderMarkdownArticle = (
  markdownit: MarkdownIt,
  markdown: string
): { html: string; headings: HeadingListItem[] } => {
  const env = {}
  const tokens = markdownit.parse(markdown, env)
  const headings: HeadingListItem[] = []

  const extractContent = (indexOfHeadingOpen: number) => {
    const inlineToken = tokens[indexOfHeadingOpen + 1]
    // 例えば見出しにリンクが含まれているとchildrenがあります。
    if (inlineToken.children === null) {
      return inlineToken.content
    }
    return inlineToken.children
      .filter((token) => token.type === 'text')
      .map((token) => token.content)
      .join('')
  }

  tokens.forEach((token, index) => {
    if (token.type !== 'heading_open') {
      return
    }
    const level = Number(token.tag.substring(1))
    const idIndex = token.attrIndex('id')
    // markdown-it-anchorが働いているのでnullにはなりませんが、静的型チェックでは判断できません。
    if (token.attrs === null) {
      return
    }
    headings.push({
      level,
      text: extractContent(index),
      id: token.attrs[idIndex][1],
    })
  })

  return {
    html: markdownit.renderer.render(tokens, markdownit.options, env),
    headings,
  }
}

生成された目次を整形する部分

const article = renderMarkdownArticle(markdown)

const generateEmpty = (level: number): Heading => {
  return {
    id: '',
    text: '',
    level,
    children: [],
  }
}

const buildHeadings = () => {
  const root: Heading = generateEmpty(0)

  const insert = (parent: Heading, child: Heading): void => {
    const nextLevel = parent.level + 1
    if (nextLevel === child.level) {
      parent.children.push(child)
      return
    }
    if (parent.children.length === 0) {
      parent.children.push(generateEmpty(nextLevel))
    }
    insert(parent.children[parent.children.length - 1], child)
  }

  article.headings.forEach((heading) => {
    insert(root, {
      id: heading.id,
      text: heading.text,
      level: heading.level,
      children: [],
    })
  })
  return root.children.length === 0 ? [] : root.children[0].children
}

解説

markdown-it-anchorのREADMEに書かれている方法を採用しない理由

このプログラムはmarkdown-it-anchorのREADMEから持ってきました。

const { parse } = require('node-html-parser')

const root = parse(html)

for (const h of root.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
  const slug = h.getAttribute('id') || slugify(h.textContent)
  h.setAttribute('id', slug)
  h.innerHTML = `<a href="#${slug}>${h.innerHTML}</a>`
}

console.log(root.toString())

このプログラムは私が実現したかったことそのものズバリではありません。しかし生成されたHTMLから見出し要素を集めれば、目次を記事の外に表示することができます。

この方法を採らなかった理由は、私がSPAとSSRのハイブリッドでこれを実現したかったからです。

node-html-parserはnodeのためのものです。ブラウザはDOMParserを持っています。使い分けたり統一するよりも、私にとっては上記の方法が簡単でした。

二度Markdownをパースしたくない

markdown-itの生成したTokenの配列から目次の一覧を作る戦略を採りました。markdownit.parse関数がそのTokenの配列を生成する関数です。

markdownit.render関数を呼ぶとHTMLが文字列として返されますが、markdownit.parse関数と合わせてMarkdownを二度パースするのは、無駄が大きい気がしました。

致命的に遅いということもないので、手段がなければ妥協したところですが、幸運なことにTokenの配列からHTMLを生成するmarkdownit.renderer.render関数がありました。