yourmystar tech blog
著者: itsuo 公開日:

Nuxt ContentV2 で Mermaid を SSR してみた

この記事はユアマイスター アドベントカレンダー 2022の 18 日目の記事です。

はじめに

Nuxt も v3 が正式リリースされ、Nuxt Content 等の周りのモジュールも開発が進んでいるようですが、Nuxt Content 用の Mermaid の module が無いようなので、色々と試行錯誤してみました。

Mermaid

Mermaid は言わずとしれたダイアグラム等の生成ライブラリなのですが、残念なところがブラウザ環境が必要なこと

文字の位置の算出にブラウザ環境が必要なため、SSR でのきれいな実装を妨げています。
現在いろいろな方法でブラウザ環境を必要としない実装を進めているようですが、いまいち決定打が無いのが実情のようです。

今のところ多くの実装は Puppeteer などのブラウザ自動ツールで実現しているようです。

Nuxt Content では(2022/12/18 現在) Mermaid の変換モジュールは提供はされていないのですが、Markdown の解析ツール群であるremarkプラグインを指定することができるので、そこに Mermaid への変換のプラグインremark-mermaidjsを指定すれば実現可能となっています。

が、話はそんなに簡単にはいきません。

いざ実行してみると図形は生成されるのですが、SVG の内部タグをコンポーネントとして解決できないという[Vue warn]がコンソールに大量に発生して精神衛生上あまりよくない状態になります。 こちらは私の推測ですが、生成された SVG のタグを Nuxt が component としてチェックしているためと思われます。(Vue には SVG を スルーするコードが存在しているようですが)

Prose Components

こちらを解消すべく独自で実装を試みます。

実装方針

今回どのように実装すべきか色々と悩みました。候補は以下

  1. MDC にて実装
  2. remark-mermaidjs と同じ様に Markdown の remark plugin として実装

1.は Prettier を VS Code 上で走らせているとインデントを削除してしまい見た目があまりよくない。
2.が現状一番良い感じがしたのでこちら実装していきます。

ディレクトリ構成

nuxt-mermaid
├─── nuxt-mermaid-module
│  └─── Nuxt Moduleを構成
└─── remark-nuxt-mermaid
   └─── remark pluginを構成

なぜ monorepo?(Nuxt Module と remark plugin を分けているのか)

Nuxt Content には remark plugin を読み込む config オプションがあるのですが、こちらが一癖あります。

値を { プラグイン名: {プラグインのオプション} } のオブジェクト形式で渡しているのですが、この値はシリアライズされます。

export default defineNuxtConfig({
  content:{
    remarkPlugins: { hoge: {} } // <- プラグイン名とオプションを指定
  }
}

plugin を取得するときには、文字列のプラグイン名(ここでいう hoge)を SSR 時に dynamic import して処理しています。そのため hogeを node_module として import できる形にしないとエラーになってします。

// nuxt-content/src/runtime/transformers/markdown.ts L 25
async function importPlugins(plugins: Record<string, false | MarkdownPlugin> = {}) {
  const resolvedPlugins = {}
  for (const [name, plugin] of Object.entries(plugins)) {
    if (plugin) {
      resolvedPlugins[name] = {
        instance: plugin.instance || (await import(/* @vite-ignore */ name).then((m) => m.default || m)), // <- ここ
        ...plugin,
      }
    } else {
      resolvedPlugins[name] = false
    }
  }
  return resolvedPlugins
}

参照ソース) nuxt-content/src/runtime/transformers/markdown.ts

(独自でさらっと remark plugin を実装できないので、文句言われていますね)

この制約を回避するため今回は Nuxt Module と remark plugin を monorepo として実装することとにしました。

処理の流れ

  1. remark plugin が Markdown 内で Mermaid のコードを検出&抽出
  2. 当該部分を Puppeteer 上で svg に変換、書き出し
  3. コンポーネントでラップする

1,2 を remark plugin 上で実装し、3 は Nuxt Module で実装します

作ったもの GitHub - nuxt-mermaid

Nuxt Content 用に remark plugin を実装

今回の実装は remark-mermaidjs をお手本としてミニマムに実装していますが、検証目的のためエラーハンドリング等は行っていません。 また、remark-mermaidjs では Chrome の executionPath を指定しないと動かない実装になっていますが、今回はローカルでの動作を前提としているため指定していません

ハマリポイント

ハマリポイントだったのが コンポーネントの指定の仕方でした。Markdown 内でコンポーネントを指定して書き出す場合、この nuxt-mermaidNuxtMermaidとパスカルケースにしたり、セルフクロージングタグにしたりすると、後続するコンテンツが表示されないバグがありました。 また svg の中に'(シングルクォーテーション)や"(ダブルクォーテーション)があるので 一旦encodeURIComponentでエンコードさせて コンポーネントに渡しています。

// remark-mermaid-plugin/src/index.ts
parent.children[index] = {
  type: 'paragraph',
  children: [
    {
      type: 'html',
      value: `<nuxt-mermaid svg-content="${encodeURIComponent(
        result.value,
      )}"></nuxt-mermaid>` /* <- これがケバブケースでないといけない */,
    },
  ],
}

Nuxt Module

Nuxt Module 側ではremarkPluginsの指定とコンポーネントの登録を行います。これらは Nuxt Module のお作法に従いsetup関数内で行います。

// nuxt-mermaid-module/src/module.ts
setup (options, nuxt) {
  nuxt.hook('nitro:config', (nitroConfig) => {
    nitroConfig.runtimeConfig = nitroConfig.runtimeConfig || {}
    nitroConfig.runtimeConfig.content.markdown.remarkPlugins = { '@nuxt-mermaid/remark-mermaid-plugin': {} }
  })
  const { resolve } = createResolver(import.meta.url)
  const runtimeDir = fileURLToPath(new URL('./runtime', import.meta.url))
  addComponent({ name: 'nuxt-mermaid', filePath: resolve(runtimeDir, 'NuxtMermaid.vue'), global: true })
}

component で wrap

どのように Vue warn を回避するか 色々とこねくり回した結果、あまり良い実装ではないのですがv-htmlを使用するとうまくいきました。

<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{ svgContent: string }>()
const content = computed(() => {
  return decodeURIComponent(props.svgContent)
})
</script>

<template>
  <div v-html="content" />
</template>

実行

実行してみると Mac の Dock 上でアイコンがうにうに動いて、Puppeteer の動作が確認できます。時間は少々かかりますが、完璧な出力を得ることができました。 良いことは、一度取得してしまえば、Nuxt Content のコンテントキャッシュのおかげで開発時でも非常に高速に閲覧できること。 Content の静的書き出しもバッチリでした。

出力された画像 Prose Components

感想

お世辞にもきれいに実装できたとは言えませんが、なんとかやりたいことはできました。 やることを掲げたはいいが、調べることが多すぎて心が折れかけました。w(片手間ではありますが、調査に 3,4 ヶ月かけています)

今回、remark, unifiedjs 等々、構文解析のライブラリは今までほとんど触ったことがなかったので(jsdoc のプラグインを少し書いたことがある)とても有意義なものとなりました。これからこの知識を 余り使うことは無いと思いますが、 生かしていけたら良いと思います。

ツイートするはてなブックマークに追加シェアする