今回達成すること
記事のアンカーリンクを生成するmarkdown-it-anchorを導入し、ブログ記事の目次を生成します。
markdown-it-anchorのインストールとセットアップ
markdown-it-anchor
をインストールします。
% yarn add markdown-it-anchor
インストールしたmarkdown-it-anchor
はmarkdownit.use
に登録しましょう。
nuxt.config.js
markdownit: {
...
use: [
['markdown-it-link-attributes', {
...
}],
// 追加
['markdown-it-anchor', {
level: [2, 3]
}]
]
},
-
level
... アンカーリンクを付与するh
タグレベルを指定する。デフォルトは1
。-
level: 1
の場合 ...h1
以下の全てのh
タグにアンカーリンクを付与する。 -
level: [2, 3]
の場合 ...h2
とh3
にアンカーリンクを付与する。
-
アンカーリンクの生成を確認する
マークダウンの記事のDOMをデベロッパーツールから確認してみましょう。
h2
とh3
にはid
とtabindex
が付与されています。
これがmarkdown-it-anchor
の機能です。
markdown-it-anchorのパーマリンクはバグあり
ちなみに、パーマリンクはanchor.permalink.linkInsideHeader({ option })
で使用可能です。(バグあり)
pages/posts/_category/_slug.vue
import anchor from 'markdown-it-anchor'
@Component
export default class PostsCategorySlugPage extends Vue {
created (): void {
// パーマリンク生成のサンプルコード
this.$md.use(anchor, {
level: [2, 3],
permalink: anchor.permalink.linkInsideHeader ({
class: 'my-anchor',
symbol: '<span class="mdi mdi-link-variant" />',
space: false,
placement: 'before'
})
})
}
}
生成されたパーマリンク
ただし、
-
asyncData()
内で実行すると初回リロード時にパーマリンクが消える -
nuxt.config.js で設定すると、サーバーサイドで関数が見つからないエラーになるError in render: "ReferenceError: e is not defined"
-
ブラウザバックするとその都度パーマリンクが増える
このようなバグが多発するので、markdown-it-anchor
のパーマリンクオプションは使用しない方が良いでしょう。
markdown-it-anchor
はVue.jsのライフサイクル上で動くことを想定していないため、このようなバグが発生するのだと思います。
どうしてもパーマリンクが必要な場合は他のプラグインを検討してください。
記事の目次を作成する
ここから生成したアンカーリンクを取得し、記事の目次を生成する実装を行います。
まず、level
を変数に置き換え、その変数をpublicRuntimeConfig
にも登録します。
nuxt.config.js
import localeJa from './locales/ja.json'
import localeEn from './locales/en.json'
// 追加
// markdown anchor level h2, h3(h2以下全てを指定したい場合は2を指定)
const mdAnchorLevel = [2, 3]
export default {
...
publicRuntimeConfig: {
appName: process.env.APP_NAME,
appEmail: process.env.APP_EMAIL,
// 追加
mdAnchorLevel
},
...
markdownit: {
...
use: [
...
['markdown-it-anchor', {
// 書き換え
level: mdAnchorLevel
// level: [2, 3]
}]
]
},
}
目次を生成するプラグインファイルの作成
目次を生成するプラグインファイル
% touch plugins/toc.ts
プラグインファイル内で、markdown-it-anchor
のコールバック関数を呼び出し、その中で目次を生成します。
plugins/toc.ts
import { Plugin, Context } from '@nuxt/types'
import MarkdownIt from 'markdown-it'
import anchor from 'markdown-it-anchor'
// mdAnchorLevelがh2, h3の場合
interface CallbackToken {
markup: '##' | '###'
}
interface CallbackInfo {
slug: string
title: string
}
export interface Toc extends CallbackInfo {
level: number
}
export interface TocInterface {
readonly $md: MarkdownIt
readonly mdAnchorLevel: number[]
readonly tocTopLevel: number
getTocs: Toc[]
}
class TableOfContents implements TocInterface {
$md
mdAnchorLevel
tocTopLevel
constructor ({ $md, $config: { mdAnchorLevel } }: Context) {
this.$md = $md
this.mdAnchorLevel = mdAnchorLevel
this.tocTopLevel = mdAnchorLevel[0]
}
// this.mdAnchorLevelレベルのToc配列を返す
get getTocs () {
const tocs: Toc[] = []
this.$md.use(anchor, {
level: this.mdAnchorLevel,
// markdown-it-anchorのコールバック関数の呼び出し
callback: (token: CallbackToken, info: CallbackInfo) => {
const toc: Toc = {
level: token.markup.length,
slug: info.slug,
title: info.title
}
tocs.push(toc)
}
})
return tocs
}
}
const toc: Plugin = (context: Context, inject) => {
inject('toc', new TableOfContents(context))
}
export default toc
markdown-it-anchorのcallback()関数
callback: (token: CallbackToken, info: CallbackInfo) => {
}
第一引数のtoken
にはアンカーリンクの詳細情報が入っています。
markup
にはマークダウン記法の記号が入っています。
'##'
=>h2
タグ'###'
=>h3
タグ
token
の中には以下の情報が入っています。
callback: (token)
Token {
type: 'heading_open',
tag: 'h3',
attrs: [
[
'id',
'custom-containers'
],
[
'tabindex',
'-1'
]
],
map: [
227,
228
],
nesting: 1,
level: 0,
children: null,
content: '',
markup: '###',
info: '',
meta: null,
block: true,
hidden: false
}
第二引数のinfo
には、アンカーリンクのid
の値とテキストが入っています。
callback: (, info)
info {
slug: 'custom-containers',
title: 'Custom containers'
}
これらを元に目次オブジェクトを生成しています。
const toc: Toc = {
// token.markup.length: h2の場合2、h3の場合は3が返る
level: token.markup.length,
slug: info.slug,
title: info.title
}
プラグインファイルの型登録
types/module.d.ts
...
import { ClientEventInterface } from '~/plugins/client-event'
// 追加
import { TocInterface } from '~/plugins/toc'
declare module 'vue/types/vue' {
// Vueインスタンス(this)の型追加
interface Vue {
...
// 追加
// plugins/toc.ts
$toc: TocInterface
}
}
declare module '@nuxt/types' {
// Nuxt Contextへの型追加
interface Context {
...
// 追加
// plugins/toc.ts
$toc: TocInterface
}
...
}
これでVueファイルから目次生成プラグインを呼び出せるようになりました。
Vueファイルから目次生成プラグインを呼び出そう
ブログコンテンツページのasyncData()
から、目次生成プラグインを呼び出しましょう。
pages/posts/_category/_slug.vue
<template>
<div>
<!-- divで囲む -->
<div>
Post title: {{ post.fields.title }}
</div>
<!-- 追加 -->
<h2>
Table of contents Test
</h2>
<ul
class="pl-0"
style="list-style: none"
>
<li
v-for="(toc, i) in tocs"
:key="`toc-${i}`"
:style="{ textIndent: `${toc.level - tocTopLevel}em` }"
>
<nuxt-link
:to="`#${toc.slug}`"
>
{{ toc.title }}
</nuxt-link>
</li>
</ul>
<!-- ここまで -->
<div
ref="postBody"
>
<!-- eslint-disable vue/no-v-html -->
<!-- v-html="markupPostBodyText"へ書き換え -->
<div
v-html="markupPostBodyText"
/>
<!-- <div
v-html="$md.render(post.fields.body)"
/> -->
</div>
</div>
</template>
<script lang="ts">
import { Context } from '@nuxt/types'
import { Component, Vue, Ref } from 'nuxt-property-decorator'
import { BlogPost } from '~/store/types'
// 追加
import { Toc } from '~/plugins/toc'
type AsyncData = void | {
post: BlogPost
// 追加
tocs: Toc[]
tocTopLevel: number
markupPostBodyText: string
}
@Component
export default class PostsCategorySlugPage extends Vue {
validate ({ params }: Context): boolean {
return !!params.slug
}
@Ref('postBody')
readonly postBody!: HTMLElement
// Contextに$md, $toc追加
asyncData ({ app: { $accessor }, params, $my, $md, $toc }: Context): AsyncData {
const post: BlogPost | undefined =
$accessor.posts.find((post: BlogPost) =>
post.fields.slug === params.slug
)
if (!post) {
return $my.errorHandler(404)
}
// 以下、追加
const tocs: Toc[] = $toc.getTocs
const tocTopLevel: number = $toc.tocTopLevel
const markupPostBodyText: string = $md.render(post.fields.body)
/*
注意
- <template>内で$md.render()を実行するとanchor.callback関数が無限ループになる
- $toc.getTocsの後に$md.render()を実行しないとanchor.callback関数が正しく動かない
フレームワークによっては、コンポーネントのライフサイクルでメモリリークや無限ループが発生する可能性があります。
VueJSのように、callback関数はレンダリングが完了すると呼び出されます。
そのため、データが更新されると、その都度render関数が呼び出されます。
バグ詳細: https://github.com/valeriangalliat/markdown-it-anchor/issues/72
*/
return {
post,
// 追加
tocs,
tocTopLevel,
markupPostBodyText
}
}
...
}
</script>
-
asyncData()
-
const tocs
... 先ほど作成したプラグインファイルのゲッターを代入。ここに目次のオブジェクトが入っている。=>
{ "level": 2, "slug": "h2-heading", "title": "h2 Heading" }
-
const tocTopLevel
...nuxt.config.js で用意したmdAnchorLevel
の1つ目の値が入る。よって今回は2
が代入されている。 -
const markupPostBodyText
... コメント「注意」に記載したバグが発生するため、markdown-it-anchor
のコールバック関数を呼び出した後に、マークダウンのrender()
を実行している。これに伴い、HTMLはv-html="markupPostBodyText"
へ変更。
-
コールバック関数の無限ループに要注意
markdown-it-anchor
のコールバック関数は、$md.render()
の呼び出し順序により無限ループが発生します。
必ずコールバック関数を呼び出した後に$md.render()
を実行してください。
// 1番目の処理
this.$md.use(anchor, {
level: this.mdAnchorLevel,
callback: (token: CallbackToken, info: CallbackInfo) => {
/* 処理 */
}
})
// 2番目の処理
$md.render(post.fields.body)
ちなみに、v-html="$md.render()"
を実行してもバグが回避できることを確認済みです。
pages/posts/_category/_slug.vue(親)
<post-slug-body
:post="post"
/>
components/PostSlugBody.vue(子)
<div
v-html="$md.render(post.fields.body)"
/>
ブラウザで目次を確認しよう
ブラウザでマークダウン記事を確認してみましょう。
h2
とh3
タグが目次リンクとなって現れました。
日本語の目次に対応しているか確認する
日本語にも対応しているか確認します。
Contentfulの適用な記事に、h2
とh3
の日本語タイトルを追加します。
Contentfulの記事bodyに以下を追加
## h2 anchor test 1
<br>
<br>
<br>
<br>
### h3 日本語 テスト
<br>
<br>
<br>
## h2 日本語 テスト
<br>
<br>
<br>
<br>
### h3 日本語 ああああ
ブラウザで該当の記事を開きましょう。
日本語の目次も生成され、クリックするとアンカーリンクまで遷移することを確認してください。
最後に本番環境で動くことも確認しておきましょう。
% yarn netlify:deploy
今回実装は以上です。
チャプターも終了するのでGitHubにPushします。
% git add -A
% git commit -m "Add plugin file for post toc"
% git checkout master
% git merge <ブランチ名>
% git push
まとめ
この記事では、マークダウン記事にアンカーリンクを生成し、記事の目次を作成しました。
上記でお伝えした通り、Nuxtでmarkdown-it-anchor
を使用すると複数のバグに遭遇します。
- パーマリンクが正しく生成されない
$md.render()
の呼び出し順序を間違えるとコールバック関数が無限ループになる
これはmarkdown-it-anchor
が悪いのではなく、Vue.jsのライフサイクルとの相性が悪いのだと思います。
ただここさえ回避できれば、一番シンプルに目次が生成できるプラグインであることも事実です。
サイトのパーマリンクの重要性も加味し、markdown-it-anchor
を採用するか最終判断してください。
チャプターまとめ
このチャプターではマークダウンテキストをブログ記事に変換するために以下のことを行いました。
- @nuxtjs/markdownitのインストールとセットアップ
- 外部リンクを操作するプラグインの導入
- 内部リンクをVueRouterで高速にページ遷移するイベントリスナーの追加
- ブログ記事のアンカーリンクと目次の生成(今ここ)
次回よりブログサイトに必須のメタデータやサイトマップの設定を行います。
2022年4月26日 追記
このカテゴリーの更新は終了しました。
残りはスタイリングとメタデータなどのSEO対策です。
この辺の情報はネットに多く転がっているのと、Nuxt内で完結する設定なので本ブログでの紹介は省略します。
Nuxtブログ SEO対策で使用するモジュール
ちなみにSEO対策で使用するモジュールは以下がおすすめです。
- Googleアナリティクス ... @nuxtjs/google-analytics
- サイトマップ ... @nuxtjs/sitemap
あとはNuxtのメタデータを設定すれば基本的なSEO対策はOKです。
ぜひチャレンジしてみてください。
2022年4月26日 完。