Gridsomeでsource-wordpress使用時に目次機能をつける。

実現したいこと

ZennのUIが好きなのですが、自分のブログにも記事の目次を付けたいなと思いました。こういうやつです。

@gridsome/vue-remarkだとそこまで苦労せずに生成できそうなのですが、私が使ってるのは@gridsome/source-wordpressなので、WordPressのAPIから取得したhtmlタグから<h2>,<h3>,<h4>タグを抜き出して目次機能を作ることにしました。

動作確認環境

 System:
    OS: Linux 4.19 Ubuntu 20.04.1 LTS (Focal Fossa)
  Binaries:
    Node: 14.15.0 - /usr/local/bin/node
    Yarn: 1.22.10 - /usr/local/bin/yarn
    npm: 6.14.8 - /usr/local/bin/npm
  npmPackages:
    @gridsome/source-wordpress: ^0.5.3 => 0.5.3
    gridsome: ^0.7.21 => 0.7.21
  npmGlobalPackages:
    @gridsome/cli: 0.3.4

大まかにやること

  1. gridsome.server.jsで記事本文から<h2>,<h3>,<h4>textContent(見出し)を抜き出す
  2. template内で目次を表示する

gridsome.server.jsで見出しタグのtextContentを抜き出す

見出しタグのtextContentを毎回動的に取得するのは無駄なので、textContentの内容をGraphQLのクエリで参照できるようにします。
見出しタグのレベルに応じてCSSのクラスを変更する都合上、該当の見出しタグのレベルも参照したいので、専用のScheme Type:Tocを定義しています。

const DOMParser = require('universal-dom-parser');
module.exports = api => {
  api.loadSource(({ addSchemaTypes, addSchemaResolvers }) => {
    // 見出しタグ用のスキーマを定義する
    addSchemaTypes(`
      type Toc implements Node {
        id: ID!
        textContent: String
        nodeName: String
      }
    `)
    addSchemaResolvers({
      BlogPost: {
        tocTargets: {
          type: "[Toc]",
          resolve(node) {
            // WordPressから取得した記事本文のhtmlをパースする
            const parser = new DOMParser();
            const doc = parser.parseFromString(`<html>${node.content}</html>`, "text/html");
            const targets = doc.querySelectorAll("h2,h3,h4");
            const tocTargets = [];
            let countId = 1;
            targets.forEach((target) => {
              tocTargets.push({
                id: `title-${countId}`,
                textContent: target.textContent,
                nodeName: `level-${target.nodeName.toLowerCase()}`,
              });
              countId++;
            });
            return tocTargets;
          },
          // 目次をクリックした際に記事の該当箇所にジャンプしたいので、
          // 見出しのタグに目次のアンカーと同じIDを追加する
          replacedContent: {
            type: "String",
            resolve(node) {
              const parser = new DOMParser();
              const doc = parser.parseFromString(`<html>${node.content}</html>`, 'text/html');
              const targets = doc.querySelectorAll("h2,h3,h4");
              let countId = 1;
              targets.forEach((target) => {
                target.id = `title-${countId}`;
                countId++;
              });
              // 見出しタグにIDを追加したhtmlを返却する
              return doc.body.innerHTML;
            }
          },
        }
      }
    })
  })
}

WordPressから取得した記事本文のhtmlをパースするためにDOMParserを使用したいのですが、Server Side Rendaringだと使えないAPIなので、coolov/universal-dom-parserでDOMParserを呼び出しています。
(もちろん、DOMParserに頼らなくても正規表現を使えば抜き出せます。)

templateで目次を表示する

<li>タグにv-forを設定し、前工程で設定したTocの配列を一覧で表示します。
h3の次にh4が来る見出しの場合、それに合わせて目次のulも入れ子にするべきですが、今回はそこまでこだわらないので、ulは入れ子にせずCSSで表示を変えています。

<template>
  <div>
    <ul>
      <!-- 記事の目次 -->
      <li
        v-for="target in $page.blogPost.tocTargets"
        :key="target.id"
        :class="target.nodeName"
      >
        <a :href="'#' + target.id">{{ target.textContent }}</a>
      </li>
    </ul>
    <!-- 記事本文 -->
    <div v-html="$page.blogPost.replacedContent" />
  </div>
</template>

<style>
.level-h2 {
  padding-left: 10px;
}
.level-h3 {
  padding-left: 20px;
}
.level-h4 {
  padding-left: 30px;
}
</style>

<page-query>
query BlogPost($path: String){
  blogPost(path:$path) {
    replacedContent
    tocTargets {
      id
      textContent
      nodeName
    }
  }
}
</page-query>

動作デモ

この記事自身の右サイドバーで動作確認できます。

余談

yaowei9363/vue-side-catalog: A Catalog Component for Vue.jsを使用すると同じことが実現できるのですが、目次のpositionをstickyにすると動作しなくなるため、導入を諦めました。自分で実装したほうが自由度が高いですし、Vueのバージョンアップ時にバグっても自分で直せるので、結果的に自作してよかったと思います。

あと、Zennのほうには月別アーカイブ機能の実装方法の記事を書きました。
Gridsomeで月別アーカイブ機能を作成する。