ymmooot

Markdown を既存の Vue コンポーネント経由でレンダリングするライブラリを作った

この記事は Vue #3 Advent Calendar 2019 24日目の記事です。

概要

Markdown をレンダリングするときに、<a> とか <code> とかを、自分で作った Vue コンポーネントでレンダリングできるライブラリ「markduck」を作りました。この記事もそのライブラリで Markdown を HTML にしています。 (弊ブログ技術スタックの変更により現在は markduck でのレンダリングはしていません。)
下の画像のような感じです。

自作の Vue の functional component で HTML 化されてることが分かる
自作の Vue の functional component で HTML 化されてることが分かる

なぜ作ったのか

Markdown を Vue.js のアプリケーションでレンダリングするとき、 marked や markdown-it で HTML に変換し、 v-html で表示していることが多いと思います。この場合、既存のデザインを記事内でも使いたい場合、厄介な事になります。

例えば、洗練されたデザインで、ソート機能をもつテーブルコンポーネント「SortableTable.vue」を持っていたとして、記事の中でもテーブルを表示したいとします。
テーブルは Markdown でサポートされているので無事 <table> やら <td> やらになりますが、パフォーマンスの問題上直接 table をセレクタにするわけには行かないので、markdown to html の処理をカスタムして class を付与したりします。
そうすると SortableTable.vue と同じ HTML を手に入れることができますが、記事を表示するコンポーネントにも SortableTable.vue と同じ CSS をディープセレクタとを使って当てるか、Sass のミックスインかなんかで持ってくる必要がありますね。頑張ったコンポーネント設計を無視して、Sass のミックスインは縦横無尽に存在できるので、個人的にはコンポーネントとの併用は好きではありません。

上記の方法でデザインを再現できても、テーブルのソートの機能は再現できません。

 

Nuxt.js を使ってて内部リンクの <a><nuxt-link> にしたい時も、イベントをキャプチャーして $router.push() を呼び出すなんて処理書きたくありません 😢

 

ということで、Markdown を自分のコンポートでレンダリングできるライブラリを作りました。

markduck

アヒルの名を冠するサービスを開発中に作ったので markduck です 🦆
以下のような感じでコンポーネントを作成します。

import createMarkduck from 'markduckjs'

const Markduck = createMarkduck({
  components: {
    h2: MyCustomHeader,
    a(nodeData) {
      const url = (nodeData.attrs && nodeData.attrs.href) || '';
      const isInternalLink = url.startsWith('/');
      return isInternalLink ? InternalLink : Link;
    },
  },
})

タグ名をキーに任意のコンポーネントを登録します。
関数で登録することもでき、引数はそのタグがレンダリングされるときの render の第二引数であるデータオブジェクトです。
上記の例では雑に内部リンクを判定して、InternalLink.vueLink.vue を返しています。
InternalLink.vue<nuxt-link> をラップしてスタイルを付与した functional コンポーネントとかにするといいんじゃないでしょうか。

全体の例はこんな感じです。

<template>
  <markduck :markdown="markdown" />
</template>

<script>
import createMarkduck from 'markduckjs';

import Head2 from '@/components/articles/Head2.vue';
import Link from '@/components/articles/Link.vue';
import InternalLink from '@/components/articles/InternalLink.vue';

export default {
  components: {
    markduck: createMarkduck({
      components: {
        h2: Head2,
        a(nodeData) {
          const url = (nodeData.attrs && nodeData.attrs.href) || '';
          const isInternalLink = url.startsWith('/');
          return isInternalLink ? InternalLink : Link;
        },
      },
    }),
  },
  props: {
    markdown: {
      type: String,
      required: true,
    },
  },
};
</script>
<!-- Head2.vue -->
<template functional>
  <h2 :class="[data.class, data.staticClass, $style.h2]">
    <slot />
  </h2>
</template>

<style lang="scss" module>
h2 {
  display: block;
  margin: 28px 0 16px;

  font-size: 1.3rem;
  padding-bottom: 4px;
  border-bottom: 1px solid gray;
}
</style>
<!-- InternalLink.vue -->
<template functional>
  <nuxt-link :to="props.href" :class="[data.class, data.staticClass, $style.link]">
    <slot />
  </nuxt-link>
</template>

<style lang="scss" module>
.link {
  position: relative;
  display: inline;
  color: #0366D6;
  cursor: pointer;

  &:hover {
    text-decoration: underline;
  }
}
</style>

* Link.vue はただの <a> なので割愛

 

上記のように、レンダリングしてるコンポーネントの子は slot に入ってきます。
また、HTML につくであろうアトリビュートは props として受け取ります。

冒頭の SortableTable.vue はどうするのか

この markduck は remark と rehype というライブラリ群を使って Markdown をパースしています。
remark・rehype は、プラグインで拡張することができ、それにより Markdown から読み取った値を自由に変更してから、アトリビュートにセットすることができます。アトリビュートにセットされた値は、前述の通り、自分が登録したコンポーネントの props に渡ってきます。
つまり、テーブルをパースしてアトリビュートにセットするプラグインを書けば、SortableTable.vue の props にテーブルのデータを受け取ることができます。

(実際のコードは間に合わなかったのであとで書きます。)

おまけ

unified・remark という技術

unified とはシンタックスツリーを用いてテキスト処理をするエコシステムのことです。
日本語だとこの記事(Remark で広げる Markdown の世界)が読みやすいです。

 

remark は unified 上で Markdown を処理するライブラリ群で、remark-parse というパッケージにより Markdown を mdast と呼ばれるシンタックスツリーに変換します。
これを HTML に変換したり、フォーマットして Markdown に戻したりすることができます。

| ........................ process ........................... |
| .......... parse ... | ... run ... | ... stringify ..........|

          +--------+                     +----------+
Input ->- | Parser | ->- Syntax Tree ->- | Compiler | ->- Output
          +--------+          |          +----------+
                              X
                              |
                       +--------------+
                       | Transformers |
                       +--------------+

ref: github.com/unifiedjs/unified#description

 

markduck では mdast を hast と呼ばれる HTML のシンタックスツリーに変換し、それを Vue の createElement で Vue コンポーネント化しています。(hast は rehype という HTML 用の unified プロセッサーの管轄)
markduck は remarkPluginrehypePlugin の二つを受け取り、世にあるプラグインや自作のものを利用できるようになっています。

 

 

まだ作ったばかりで改良の途中ですが、このブログ程度なら使い物になります。
徐々に育てて行ければいいなと思っています。