[Nuxt Content] v2のMarkdownでMermaid記法を使えるようにする

2024/08/08

author

masyus

このサイトには技術系記事が多く、

  • DBスキーマ
  • フローチャート
  • シーケンス図
  • etc.

で解説したいケースが多々ありました。Mermaidはこうした情報をJSで簡単にビジュアライズすることができて便利ですが、Nuxt Content v2へ導入するにあたり克服すべき課題がありました。今回はその課題と解消方法について解説します。

バージョン情報

  • Nuxt: 3.11.1
  • Nuxt Content: 2.12.1
  • TypeScript: 5.4.3
  • Mermaid: 10.9.1

Nuxt Content v2にMermaidを導入する方法

課題を解説する前に、先に最終的な導入方法を解説します。まだ試行錯誤している最中ではありますが、私はMermaidをBlock Componentsに切り出して導入する方式を取りました。

::mermaid
sequenceDiagram
    participant Alice
    participant Bob
    Alice->>John: Hello John, how are you?
    loop HealthCheck
        John->>John: Fight against hypochondria
    end
    Note right of John: Rational thoughts <br/>prevail!
    John-->>Alice: Great!
    John->>Bob: How about you?
    Bob-->>John: Jolly good!
::

ルートディレクトリ配下のcontent/articles/hoge.mdに当記事冒頭のシーケンス図を、上記のようにMermaid記法で書き出します。

<script setup lang="ts">
import mermaid from 'mermaid'

const show: Ref<boolean> = ref(false)
const content = ref<HTMLDivElement | null>(null)

onMounted(async() => {
  show.value = true
  await nextTick()
  if (content.value !== null) {
    content.value.innerHTML = content.value.innerHTML.replace(/<span>/g, '[').replace(/<\/span>/g, ']')
  }
   mermaid.initialize({startOnLoad: true })
   mermaid.init()
})
</script>

<template>
  <div class="w-full my-4">
    <div v-if="show" class="mermaid flex justify-center items-center" ref="content">
      <ContentSlot :use="$slots.default" unwrap="p" />
    </div>
    <div v-else>
      <Spinner />
    </div>
  </div>
</template>

次にMermaid.vueの中身です。Block Components且つ記事ページでのみ使うため、ルートディレクトリ配下のcomponents/content/Mermaid.vueに上記を書き出しました。CSSのクラスはTailwind CSSを使っていますが、状況に応じて適宜書き換えてください。

<script setup lang="ts">
</script>

<template>
  <div class="flex justify-center items-center h-[200px]">
    <div class="animate-spin rounded-full w-20 h-20 border-8 border-gray-300 border-t-gray-800"></div>
  </div>
</template>

components/Spinner.vueはいろいろデザイン方法あると思いますが、私はTailwind CSSで上記のように実装しました。CLSをなるべく抑えたかったので適当にh-[200px]を入れていますが、より良い方法を模索中です。

Mermaid導入のための調査

Googleにて「Nuxt Content v2 Mermaid」で検索した結果の最上位にヒットしたのが、nuxt/contentのGitHubのIssuesに挙がっていた Mermaid Support #1866 でした。こちらのIssueを読むに、Mermaidの導入は

  • Block Componentsを使う
  • remarkPluginsを使う

のいずれかで自作できそうなことがわかりました。Issueにおける最終的な解決案は1.でしたので、私もBlock Componentsを使う方式を採用した次第です。

ただ、Issueに記載の「動いた!」という事例を手元で再現してみたところ、どうも中途半端でバグっていることが分かりました。以下で解説します。

Mermaid導入で参考にしたコード

参考にしたコードはIssueに記載されていた

import mermaid from "mermaid/dist/mermaid"

export default defineNuxtPlugin((nuxtApp) => {
    nuxtApp.provide('mermaid', () => mermaid)
})
<template>
    <div class="mermaid" v-if="show">
        <slot></slot>
    </div>
</template>

<script setup>

let show = ref(false);

const { $mermaid } = useNuxtApp()

onMounted( async() => {
  show.value = true
  $mermaid().initialize({startOnLoad: true })
  await nextTick()
  $mermaid().init();
})

</script>

です。処理の流れを時系列で読み解くと、下記のようになります。

  • Mermaidをimportしつつプラグイン化して、アプリケーション内のどこからでも使えるようにする
  • Markdownで記述されたMermaid記法のテキストがslotにレンダリングされる
  • 2.のレンダリングが完了するまでの間はslotの中身を表示しない(レンダリング完了前にmermaid()を呼び出すと、slotの中身をパースできないため)
  • マウントが完了し、show.valueがtrueに切り替わりDOMの更新も完了した上でmermaid()を実行したいので、その手前でnextTick()を呼び出す
  • mermaid().init()を実行し、class="mermaid"が適用されているDOMのinnerHTMLをパースしてダイアグラムを書き出す

この流れ自体は特に問題ありませんでした。強いて挙げるならこのサイトでMermaidが必要となるのは記事ページのみでしたため、pluginにせずBlock Components内でimportするスタイルを取ったくらいでしょうか。書き換えると下記のようになりました。

<script setup lang="ts">
import mermaid from 'mermaid'

const show: Ref<boolean> = ref(false)

onMounted(async() => {
  show.value = true
  await nextTick()
  mermaid.initialize({startOnLoad: true })
  mermaid.init()
})
</script>

<template>
  <div class="w-full my-4">
    <div v-if="show" class="mermaid flex justify-center items-center">
      <slot />
    </div>
    <div v-else>
      <Spinner />
    </div>
  </div>
</template>

ただ、これだとmermaid().init()時にMermaidでSyntax error in textが発生しました。以下で解説します。

課題1: Prose ComponentのProsePでslotがラッピングされてしまう

Block Componentsを利用している為に、デフォルトでは<p></p>で囲われた状態のMarkdownテキストが<slot />に渡ってしまいます。これがSyntax errorになる原因でした。

解決策: unwrapを使い、pタグでslotがラッピングされないようにする

幸いなことにNuxt Content v2ではspecial slotとして<ContentSlot />が提供されており、ContentSlotの1機能であるunwrapを使うことで解消できました。

<ContentSlot :use="$slots.default" unwrap="p" />

このように記述することで、pタグで囲われないように指定することができます。

課題2: []が<span></span>タグに変換されてしまう

Mermaid記法はフローチャートやシーケンス図を表すための基本構成要素として

  • nodes: 幾何学的形状(例: 四角や三角など)
  • edges: 矢印もしくは線(例: →など)

があります。今回課題となったのは

nodesのA node with textと、Nuxt Content v2のSpan Textの食い合わせが悪かったこと

です。A node with textはnodeに対し[]を使うことでテキストを補足するためのものですが、Nuxt Content v2のMarkdownではSpan Text機能により[]<span></span>に変換されてしまいます。そうなると、slotの中身にspanタグが混入し、mermaid().init()時にSyntax error in textが発生してしまいます。これをどのように回避するかで苦戦しました。

解決策: <span></span>タグを[]に変換し直す

unwrap="p,span"と指定することも考えましたが、unwrapでspanを指定すると[]自体が無くなってしまうため、Mermaid記法でA node with textの表現が使えなくなってしまいます。

そのため別の方策を検討した結果、一度slotにレンダリングされたMarkdownコンテンツの中に混入した<span></span>タグを[]に変換し直す処理を加えることにしました。改良したコードは下記です。

<script setup lang="ts">
import mermaid from 'mermaid'

const show: Ref<boolean> = ref(false)
const content = ref<HTMLDivElement | null>(null)

onMounted(async() => {
  show.value = true
  await nextTick()
  if (content.value !== null) {
    content.value.innerHTML = content.value.innerHTML.replace(/<span>/g, '[').replace(/<\/span>/g, ']')
  }
   mermaid.initialize({startOnLoad: true })
   mermaid.init()
})
</script>

<template>
  <div class="w-full my-4">
    <div v-if="show" class="mermaid flex justify-center items-center" ref="content">
      <ContentSlot :use="$slots.default" unwrap="p" />
    </div>
    <div v-else>
      <Spinner />
    </div>
  </div>
</template>

<ContentSlot />でレンダリングされる内容を、親のdivに仕込んだref="content"にてJSでリアクティブに取り扱えるようにします。あとはinnerHTMLの中身を煮るなり焼くなりです。

あとがき

こうして要点まとめて書き出してみると簡単ですが、起きてる事象を把握して原理を知り、原因を特定するまではそこそこ時間を使いました。個人的にNuxt Contentは慣れ親しんでおり且つ好きなので、これを機にNuxt Contentを使う方が増えたら嬉しい限りです。

参考

Tailwind CSSの関連記事

まだ記事がありません