実現したいこと
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
大まかにやること
gridsome.server.js
で記事本文から<h2>
,<h3>
,<h4>
のtextContent
(見出し)を抜き出す- 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で月別アーカイブ機能を作成する。