Nuxt.js×markdown-it アンカーリンクとブログ目次を自動生成しよう
  • 2022.03.04に公開
  • 2022.04.26に更新
  • ブログ構築TS
  • 9. ブログMarkdown対応
  • No.4 / 4

今回達成すること

記事のアンカーリンクを生成するmarkdown-it-anchorを導入し、ブログ記事の目次を生成します。

2022-03-01 22-06-42

markdown-it-anchorのインストールとセットアップ

markdown-it-anchorをインストールします。

% yarn add markdown-it-anchor

インストールしたmarkdown-it-anchornuxt.config.jsmarkdownit.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]の場合 ... h2h3にアンカーリンクを付与する。

アンカーリンクの生成を確認する

マークダウンの記事のDOMをデベロッパーツールから確認してみましょう。

h2h3にはidtabindexが付与されています。

これがmarkdown-it-anchorの機能です。

2022-02-28 21-28-25

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'
      })
    })
  }
}
生成されたパーマリンク

2022-03-01 09-21-54

ただし、

  • asyncData()内で実行すると初回リロード時にパーマリンクが消える

  • nuxt.config.jsで設定すると、サーバーサイドで関数が見つからないエラーになる

    Error in render: "ReferenceError: e is not defined"

  • ブラウザバックするとその都度パーマリンクが増える

2022-03-01 09-17-54

このようなバグが多発するので、markdown-it-anchorのパーマリンクオプションは使用しない方が良いでしょう。

markdown-it-anchorはVue.jsのライフサイクル上で動くことを想定していないため、このようなバグが発生するのだと思います。

どうしてもパーマリンクが必要な場合は他のプラグインを検討してください。

記事の目次を作成する

ここから生成したアンカーリンクを取得し、記事の目次を生成する実装を行います。

まず、nuxt.config.jslevelを変数に置き換え、その変数を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]
      }]
    ]
  },
}

目次を生成するプラグインファイルの作成

目次を生成するプラグインファイルtoc.tsを作成します。

% 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
}

プラグインファイルの型登録

module.d.tsにプラグインファイルの型登録を行います。

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ファイルから目次生成プラグインを呼び出そう

ブログコンテンツページの_slug.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)

ちなみに、_slug.vueの子コンポーネントで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)"
/>

ブラウザで目次を確認しよう

ブラウザでマークダウン記事を確認してみましょう。

h2h3タグが目次リンクとなって現れました。

2022-03-01 22-06-42

日本語の目次に対応しているか確認する

日本語にも対応しているか確認します。

Contentfulの適用な記事に、h2h3の日本語タイトルを追加します。

Contentfulの記事bodyに以下を追加
## h2 anchor test 1
<br>
<br>
<br>
<br>

### h3 日本語 テスト
<br>
<br>
<br>

## h2 日本語 テスト
<br>
<br>
<br>
<br>

### h3 日本語  ああああ

ブラウザで該当の記事を開きましょう。

日本語の目次も生成され、クリックするとアンカーリンクまで遷移することを確認してください。

2022-03-01 22-12-13

最後に本番環境で動くことも確認しておきましょう。

% 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を採用するか最終判断してください。

チャプターまとめ

このチャプターではマークダウンテキストをブログ記事に変換するために以下のことを行いました。

次回よりブログサイトに必須のメタデータやサイトマップの設定を行います。

2022年4月26日 追記

このカテゴリーの更新は終了しました。

残りはスタイリングとメタデータなどのSEO対策です。

この辺の情報はネットに多く転がっているのと、Nuxt内で完結する設定なので本ブログでの紹介は省略します。

Nuxtブログ SEO対策で使用するモジュール

ちなみにSEO対策で使用するモジュールは以下がおすすめです。

あとはNuxtのメタデータを設定すれば基本的なSEO対策はOKです。

ぜひチャレンジしてみてください。

2022年4月26日 完。

あなたの力になれること
私自身が独学でプログラミングを勉強してきたので、一人で学び続ける苦しみは痛いほど分かります。そこで、当時の私がこんなのあったら良いのにな、と思っていたサービスを立ち上げました。周りに質問できる人がいない、答えの調べ方が分からない、ここを聞きたいだけなのにスクールは高額すぎる。そんな方に向けた単発・短期間メンターサービスを行っています。
独学プログラマのサービス
ブログ構築TSの投稿
1
  • Nuxt.js×TypeScript開発環境構築
  • /
  • #01
Nuxt.jsをローカルPCに立ち上げよう
2
  • Nuxt.js×TypeScript開発環境構築
  • /
  • #02
Nuxt.jsプロジェクトをGitHubにPushしよう
3
  • Nuxt.js×TypeScript開発環境構築
  • /
  • #03
nuxt-property-decoratorのインストールとTypeScriptのセットアップ
1
  • Vuetifyセットアップ
  • /
  • #01
TypeScript環境のNuxt.jsにVuetifyを導入しよう
2
  • Vuetifyセットアップ
  • /
  • #02
VuetifyにカスタムCSSを追加してSASS変数を理解しよう
3
  • Vuetifyセットアップ
  • /
  • #03
VuetifyにカスタムSVGアイコンを追加しよう
1
  • NetlifyCLIを使ったNuxtデプロイ
  • /
  • #01
Netlify CLIをインストールして本番環境のサイトを作成しよう
2
  • NetlifyCLIを使ったNuxtデプロイ
  • /
  • #02
netlify.tomlを使ってNuxt.jsをNetlifyに手動デプロイしよう
1
  • Contentfulモデル構築
  • /
  • #01
Contentfulの料金とCommunityプランの無料枠を理解する
2
  • Contentfulモデル構築
  • /
  • #02
Contentfulへ新規会員登録、ロケールの変更、API Keyの発行を行う
3
  • Contentfulモデル構築
  • /
  • #03
Contentful ブログカテゴリーモデルを作成しよう
4
  • Contentfulモデル構築
  • /
  • #04
Contentful カテゴリーモデルに1対1で関連づくblogPostモデルを作成しよう
5
  • Contentfulモデル構築
  • /
  • #05
Contentful ブログ記事に1対多で関連づくplogTagモデルを作成しよう
6
  • Contentfulモデル構築
  • /
  • #06
Contentful カテゴリー・ブログ記事・タグコンテンツを作成しよう
1
  • Nuxt.js×Contentfulセットアップ
  • /
  • #01
Nuxt.js×Contentfulセットアップ。モジュールのインストールからAPI Keyの登録まで
2
  • Nuxt.js×Contentfulセットアップ
  • /
  • #02
Contentful APIリクエストの実行 Nuxt.jsにブログコンテンツを表示しよう
3
  • Nuxt.js×Contentfulセットアップ
  • /
  • #03
ContentfulAPIをNetlifyにデプロイしよう【Nuxt FullStaticのasyncDataとfetch】
1
  • Vuex×TypeScriptセットアップ
  • /
  • #01
Vuexの型付け vuex-module-decoratorsとnuxt-typed-vuexどちらを使用するか
2
  • Vuex×TypeScriptセットアップ
  • /
  • #02
nuxt-typed-vuexのインストールとセットアップ。Vuexの型定義と呼び出し方
3
  • Vuex×TypeScriptセットアップ
  • /
  • #03
VuexにContentfulの型定義ファイルとnuxtServerInitを追加しよう
4
  • Vuex×TypeScriptセットアップ
  • /
  • #04
VuexにContentfulAPIレスポンスを保存してVueファイルに表示しよう
1
  • コンテンツページ構築
  • /
  • #01
ブログアプリのページ設計とNuxt.jsの動的ルーティングについて理解しよう
2
  • コンテンツページ構築
  • /
  • #02
カテゴリーのコンテンツページを作成しよう【Nuxt.js×Contentful】
3
  • コンテンツページ構築
  • /
  • #03
カテゴリーに関連付くブログ記事一覧を表示しよう【Nuxt.js×Contentful】
4
  • コンテンツページ構築
  • /
  • #04
injectを使用して共通エラー処理メソッドを作成しよう【Nuxt×TypeScript】
5
  • コンテンツページ構築
  • /
  • #05
NuxtChildを使用してブログ記事ページを作成しよう【Nuxt.js×TypeScript】
6
  • コンテンツページ構築
  • /
  • #06
タグ一覧ページとタグ関連づく記事一覧を表示しよう【Nuxt.js×TypeScript】
7
  • コンテンツページ構築
  • /
  • #07
プライバシーポリシーページを作成しよう【Nuxt.js×TypeScript】
8
  • コンテンツページ構築
  • /
  • #08
@nuxtjs/i18nのインストールとセットアップ。ページタイトルの翻訳化【TypeScript】
1
  • NetlifyFunctionsを使った検索機能
  • /
  • #01
Netlify Functionsを使ってクエリを返す関数を作成しよう【Nuxt.js×TypeScript】
2
  • NetlifyFunctionsを使った検索機能
  • /
  • #02
Netlify Functionsプロジェクトをデプロイしよう【Nuxt.js×TypeScript】
3
  • NetlifyFunctionsを使った検索機能
  • /
  • #03
Nuxt.js × axiosセットアップ Netlify Functionsにリクエストを行う準備をしよう
4
  • NetlifyFunctionsを使った検索機能
  • /
  • #04
オリジン•CORS•プリフライトリクエストを理解する【Nuxt.js×Netlify Functions】
5
  • NetlifyFunctionsを使った検索機能
  • /
  • #05
Netlify Functionsを使ってフォームバリデーション機能を構築しよう【Nuxt.js】
6
  • NetlifyFunctionsを使った検索機能
  • /
  • #06
ツールバーに表示する検索フォームを作成しよう【Nuxt.js×TypeScript】
7
  • NetlifyFunctionsを使った検索機能
  • /
  • #07
検索ページを作成しよう【Vue propsとTypeScriptの書き方 解説】
8
  • NetlifyFunctionsを使った検索機能
  • /
  • #08
Netlify FunctionsからContentfulAPIリクエストを送ろう【Nuxt.js】
9
  • NetlifyFunctionsを使った検索機能
  • /
  • #09
検索ページに「もっと見る」ボタンを実装しよう【Nuxt.js×TypeScript】
1
  • ブログMarkdown対応
  • /
  • #01
@nuxtjs/markdownitのインストールとセットアップ【Nuxt.js×TypeScript】
2
  • ブログMarkdown対応
  • /
  • #02
Nuxt.js×markdown-it 外部リンクを別タブで開くプラグインを追加しよう
3
  • ブログMarkdown対応
  • /
  • #03
Nuxt.js×markdown-it 内部リンクをVueRouterで高速にページ遷移しよう
4
  • ブログMarkdown対応
  • /
  • #04
Nuxt.js×markdown-it アンカーリンクとブログ目次を自動生成しよう
独学プログラマ
独学でも、ここまでできるってよ。
CONTACT
Nuxt.js制作のご依頼は下記メールアドレスまでお送りください。