Nuxt.jsでMarkdownファイルからFAQページなどを作れるようしてみた(.mdファイル内でコンポーネントも使える)

ほそぼそと作っているWebサービスの[積読ハウマッチ](https://tsundoku.site)、
[Gigazineさん](https://gigazine.net/news/20190907-tsundoku-how-much-review/)に紹介されました🎉🎉🎉

おかげさまでユーザやアクセスが増えたのですが、
問い合わせやコメントも急増したので、FAQを用意したほうがいいなと...

Nuxt.jsのページのうち、FAQのような静的なページだけ、
Markdownで作成できないかを調査したときの備忘録。

環境はnuxt(2.8.1) / typescript(3.6.2) 


#### ちょっと長めですが...

1. Markdownだけのシンプルな方法と
2. Markdown内でVueコンポーネントを使う凝った方法

の2パターンをまとめています。

これを使うと一部のページをマークダウンで書けるので、
FAQとか以外に**ブログとか記事とかも簡単にmarkdownで書けるようになるかもと**。

**長めですが、見出しとソースコードを見てくとだいたい分かるかも**。

---

### できあがったもの: 1.シンプルな方法

こんな感じのMarkdownファイルを読み込んで

```markdown
# よくある質問 / FAQ

## 積読本しか登録してはいけないのですか？

そんなことないです！**読み終わった本だけでも OK です ♪**
まだ積読ハウマッチで**誰も登録していない本**もあるので、
**おすすめの本**などあればどんどん登録してみてください！

また、同じ本を読んでいる人や積んでる人もわかるので、
**本の好みが近い人が見つかるかも知れません** 😊
```

[こんな感じの画面](https://tsundoku.site/faq)を作れちゃいます!

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478782/24182a0b-eb74-44cd-7200-6752dd57e152.png" width="600"/>

### できあがったもの: 2.凝った方法

ツイートを埋め込めるよう、Markdownファイルの中で
[vue-tweet-embed](https://github.com/tonickkozlov/vue-tweet-embed#readme)を利用しています。

```markdown
# 更新情報

## 2019/09/09 　洋書検索に対応 ✨

<Tweet id="1170893520787271680" :options="{ conversation: 'none' }"></Tweet>
```

1.の方法だと`<Tweet>`タグがそのままのため、なにも表示されませんが、
2.の方法だと、[こんな感じの画面](https://tsundoku.site/updates)を作れちゃいます!

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478782/75231d61-c3a5-9309-c56c-580ea718b0a7.png" width="600"/>

----

# ここから、それぞれの方法の説明

## 1. Markdownだけのシンプルな方法

シンプルな方法はこんな感じ

1. Markdownファイルを読み込んで、
2. markedでHTML化して、
3. v-htmlでHTMLを挿入

Nuxt.jsでは、Markdownファイル(.md)のようなファイルを
そのまま取り込む方法が無いので、
Webpackプラグインの[raw-loader](https://github.com/webpack-contrib/raw-loader)を使います。


#### raw-loaderなどのインストール

必要なパッケージをインストール

```console
$ npm install --save raw-loader marked highlightjs
```

#### nuxt.config.jsの設定

.mdを読み込む際に、raw-loaderを使うように設定。

```typescript
import NuxtConfiguration from "@nuxt/config";

const config: NuxtConfiguration = {
  build: {
    extend(config, ctx) {
      if (!!config.module) {
        // .mdファイルだったら、raw-loaderを使うように設定
        config.module.rules.push({ test: /\.md$/, use: ["raw-loader"] });
      }
    }
  },
}
```


#### .vueはこんな感じ

これだけで、.mdファイルを読み込んでページが作れちゃいます！
あとは読み込むファイルを変えるだけで、簡単に静的なページを量産(*´ω｀*)

```html
<template>
  <!-- v-htmlでmarked()の結果を渡す -->
  <div class="marked" v-html="text"></div>
</template>

<script lang="ts">
import { Component, Vue } from "nuxt-property-decorator";
import marked from "marked";
import hljs from "highlightjs";

// requireで.mdファイルを読み込む。
const mdText = require("~/assets/faq.md");

@Component
export default class FaqPage extends Vue {
  created() {
    // 作成時にmarkedの初期設定
    // ハイライトにhighlightjsを使うようにする
    marked.setOptions({
      langPrefix: "",
      breaks: true,
      highlight: function(code, lang) {
        return hljs.highlightAuto(code, [lang]).value;
      }
    });
  }
  // ****************************************************
  // * computed
  // ****************************************************
  private get text() {
    // 読み込んだ.mdファイルをmarkedでHTML化する
    return marked(mdText.default);
  }
}
</script>
```

そのままだといい感じの見た目にならないので、
`class="marked"`のようにしておいて、
markdown用のCSSを設定していけばOK。

どんなCSSがいいかは、[前の記事](https://qiita.com/kira_puka/items/8b5bc56a78fbb98d3df9#%E3%83%9E%E3%83%BC%E3%82%AF%E3%83%80%E3%82%A6%E3%83%B3%E7%94%A8%E3%81%AEcss%E3%82%92%E8%BF%BD%E5%8A%A0%E3%81%99%E3%82%8B)を参照ください。

#### 注意: ハマった点...

2つ注意というか、ハマった点...

##### A) .mdファイルを読み込むときはrequireを使う

ぼくの環境だけかも知れませんが、
`import mdText from "~/assets/faq.md"`とすると
VSCode上でエラーがきませんでした。。
TypeScriptの設定やVeturの問題？

そのため、requireを使い、
`const mdText = require("~/assets/faq.md");`
としてます。

それによりモジュールとして読み込まれてしまうため、
`mdText.default`でテキストを取得しています。

(本当はimport文がいいんですが、あきらめてrequireに...)

##### B) v-htmlで挿入したHTMLにScoped CSSが効かない

VueでScoped CSSを使う場合、[data-v-\*属性が付与されます]([https://vue-loader.vuejs.org/guide/scoped-css.html](https://vue-loader.vuejs.org/guide/scoped-css.html))が、
v-htmlでHTMLを挿入しているため、その属性がついていません。。

これにより、Scoped CSSが効かないため、
markdown用のCSSを利用する際はグローバルで設定する必要があります。

(これでだいぶハマりました...)

----

## 2. コンポーネントを使う凝った方法

1.の方法で、ツイートを埋め込んでみようと思ったらうまく行かず...
vueでツイートを埋め込むには、[vue-tweet-embed](https://github.com/tonickkozlov/vue-tweet-embed)が必要なようで、
以下のようにしてみたら、

```markdown
# 更新情報

## 2019/09/09 　洋書検索に対応 ✨

<Tweet id="1170893520787271680" :options="{ conversation: 'none' }"></Tweet>
```

こんな感じに...

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478782/ae9412bf-ae08-f681-53f7-807a3111721a.png" width="600"/>

ツイッターで、

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">Vueのv-htmlのテキストにコンポーネントをいれることってできないかなぁ？<br><br>markdownのなかにコンポーネント書けると色々便利そう。。</p>&mdash; 積読ハウマッチ📚きらぷか (@kira_puka) <a href="https://twitter.com/kira_puka/status/1171077003694362624?ref_src=twsrc%5Etfw">September 9, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

と悩んでたところ、[@mizuki_r](https://twitter.com/mizuki_r)さんから、こんなアドバイスが(*´ω｀*)

> v-htmlだとvueの解釈は出来ないので、独自のdirective定義してvueをコンパイルできるようにしたことはあります

> vueのtemplateオプションはHTML文字列を渡してvueのcomponentとしてcompileします。つまり、directiveで受け取ったHTML文字列をvueのtemplateオプションに渡してvueのコンポーネントを作ると…？

なるほど...( ﾟдﾟ)!

#### 大まかな流れ

1. [カスタムディレクティブ](https://jp.vuejs.org/v2/guide/custom-directive.html)を用意
2. カスタムディレクティブ内で[コンポーネント](https://jp.vuejs.org/v2/guide/components.html)を作成し、
3. コンポーネントのtemplateに`marked()`のHTMLを渡して、
4. Vueのコンパイル(=`$mounte()`の呼び出し)
5. Vueのコンパイルした結果からHTML要素を取得(`$el`)して、
6. カスタムディレクティブを設定したHTML要素の子に挿入(`el.appendChild(instance.$el)`)

長いし、複雑...


#### .vueはこんな感じ

.vue自体はほぼ同じ。
`v-html`がカスタムディレクティブの`v-md`に変わってるだけ。

```html
<template>
  <!-- **v-md**でmarked()の結果を渡す -->
  <div class="marked" v-md="text"></div>
</template>

<script lang="ts">
// ... 略
</script>
```

#### とりあえず、vue-tweet-embedのインストール

とりあえず、vue-tweet-embedを使うのでインストール

```console
$ npm install --save vue-tweet-embed
```


#### カスタムディレクティブ内で動的コンパイル

カスタムディレクティブの用意していく。`~/plugins/v-md.ts`に作成

```typescript
import Vue from "vue";
import { Tweet } from "vue-tweet-embed";

Vue.directive("md", {
  inserted: function(el, binding) {
    // v-md="value"のvalue部分を取得
    const val = binding.value;
    
    // コンポーネントを作成。ルートが1つになるように<div>で囲む
    // Tweetをコンポーネントで使うので、componentsも設定
    const cmp = Vue.extend({
      components: { Tweet },
      template: `<div>${val}</div>`
    });
    
    // Vueコンポーネントをコンパイルして、インスタンスを取得
    const instance = new cmp().$mount();
    
    // インスタンス化からHTML要素を取得して、
    const element = instance.$el
    // カスタムディレクティブを設定したHTML要素の子に挿入
    el.appendChild(element);
  }
});
```

#### 作ったプラグインをnuxt.config.tsに追加

作ったプラグインを有効にするため、nuxt.config.tsに設定を追加

```typescript
import NuxtConfiguration from "@nuxt/config";

const config: NuxtConfiguration = {
  plugins: [
    { src: "~/plugins/v-md", ssr: false },
  ],
}
```

#### これでいけるかと思いきや...

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478782/d3ac53a2-ca4c-3a0f-243d-0dc1d1f4febd.png" width="600"/>


まっしろ(*´ω｀*)
タイトルすら出ない...


Consoleを見るとエラーが...
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478782/cc06fe27-7d63-beae-fed8-3ffe674915f8.png" width="600"/>

> error [Vue warn]: You are using the runtime-only build of Vue where the template compiler is not available. Either pre-compile the templates into render functions, or use the compiler-included build.

メッセージを見てみると、
「ランタイム限定なのでコンパイルできないよ！」
とのこと...なるほど...


このあたりを見てみると、「ランタイム + コンパイラ」を選択できるので、
nuxt.config.tsの設定を変更していく。

- [DOM テンプレートパース時の警告 — Vue.js](https://jp.vuejs.org/v2/guide/components.html#DOM-%E3%83%86%E3%83%B3%E3%83%97%E3%83%AC%E3%83%BC%E3%83%88%E3%83%91%E3%83%BC%E3%82%B9%E6%99%82%E3%81%AE%E8%AD%A6%E5%91%8A)
- [ランタイム + コンパイラとランタイム限定の違い — Vue.js](https://jp.vuejs.org/v2/guide/installation.html#%E3%83%A9%E3%83%B3%E3%82%BF%E3%82%A4%E3%83%A0-%E3%82%B3%E3%83%B3%E3%83%91%E3%82%A4%E3%83%A9%E3%81%A8%E3%83%A9%E3%83%B3%E3%82%BF%E3%82%A4%E3%83%A0%E9%99%90%E5%AE%9A%E3%81%AE%E9%81%95%E3%81%84)


#### 実行時にコンパイルできるように設定を変更

公式ドキュメントの「[さまざまなビルドについて](https://jp.vuejs.org/v2/guide/installation.html#%E3%81%95%E3%81%BE%E3%81%96%E3%81%BE%E3%81%AA%E3%83%93%E3%83%AB%E3%83%89%E3%81%AB%E3%81%A4%E3%81%84%E3%81%A6)」を見ると、
`vue/dist/vue.common`を使えば良さそう。

なので、`build`に以下の3行を追加。

```typescript
import NuxtConfiguration from "@nuxt/config";

const config: NuxtConfiguration = {
  plugins: [
    { src: "~/plugins/v-md", ssr: false },
  ],
  
  build: {
    extend(config, ctx) {
      if (!!config.module) {
        config.module.rules.push({ test: /\.md$/, use: ["raw-loader"] });
      }

      // 「ランタイム限定」から「ランタイム + コンパイラ」に変更
      if (!!config.resolve && !!config.resolve.alias) {
        config.resolve.alias["vue$"] = "vue/dist/vue.common";
      }
    }
  },
}
```

すると...

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478782/75231d61-c3a5-9309-c56c-580ea718b0a7.png" width="600"/>

出た(*´ω｀*)!!


#### 注意: 未解決...⇒ 解決ヽ(=´▽`=)ﾉ

1点対応していない部分が...
Markdownファイル内で`<nuxt-link>`を使うと、エラーが...

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478782/6d8f508f-8c9c-b807-d105-f1ad4918fd8a.png" width="600"/>

> app.js:1971 Uncaught TypeError: Cannot read property 'resolve' of undefined

Nuxt側からcontextやvue-routerの情報を渡していないためだと思いますが、
詳しく見れていませんが、nuxt-linkを使うとエラーになるため、ご注意ください...

##### 追記: 2019/09/13

[Vue Routerの公式ドキュメント](https://router.vuejs.org/ja/guide/#html)を見てたら、ちゃんと書いてあった。。

Nuxt.jsのContextにあるrouterをわたして、InjectしてあげればOK!
なので、カスタムディレクティブでrouterを渡すように変更していく

それぞれこんな感じに。

###### .vueファイル

```html
<template>
  <!-- **v-md**でmarked()とrouterの結果を渡す -->
  <div class="marked" v-md="{ text: text, router: $router }"></div>
</template>
```

###### カスタムディレクティブ

```typescript
import Vue from "vue";
import { Tweet } from "vue-tweet-embed";

Vue.directive("md", {
  inserted: function(el, binding) {
    // v-md="value"のvalue部分を取得
    const { text, router } = binding.value;
    
    // コンポーネントを作成。ルートが1つになるように<div>で囲む
    // Tweetをコンポーネントで使うので、componentsも設定
    const cmp = Vue.extend({
      components: { Tweet },
      template: `<div>${val}</div>`
    });
    
    // Vueコンポーネントをコンパイルして、インスタンスを取得
    // <nuxt-link>を使えるようインスタンス生成時にrouterを渡す
    const instance = new cmp({ router }).$mount();
    
    // インスタンス化からHTML要素を取得して、
    const element = instance.$el
    // カスタムディレクティブを設定したHTML要素の子に挿入
    el.appendChild(element);
  }
});
```

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">なんかいじってたら、前できなかった.mdファイルにnuxt-link入れるのできた(*´ω｀*)<br><br>なるほど、routerを渡してなかったからダメなのか...<br><br>気合い入れるとダメだけど、横道にそれるとうまくいくことが最近多い(*´ω｀*) <a href="https://t.co/3Hg8qw3nGp">pic.twitter.com/3Hg8qw3nGp</a></p>&mdash; 積読ハウマッチ📚きらぷか (@kira_puka) <a href="https://twitter.com/kira_puka/status/1172046659041185792?ref_src=twsrc%5Etfw">September 12, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

## おわりに

marked+highlightjs+raw-loaderを使って、
Markdownファイルでページコンテンツを作れるように!

カスタムディレクティブを使うと、
Markdownファイル内でVueコンポーネントを使えるように!

今回のアップデートでFAQや更新情報のページに利用してますが、
サイトの説明や使い方ページなどにも使えたり、
Nuxt.jsアプリにmarkdownで書いた記事なども簡単にできそう(*´ω｀*)


## こんなのつくってます！！

上記を使ってFQAや更新情報ページを追加した、積読用の読書管理アプリ「積読ハウマッチ」！
[積読ハウマッチ](https://tsundoku.site)はNuxt.js(SPA)+Firebaseで開発してます!

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/478782/572d4947-f40b-e4dc-1c9c-bc584cd2a66c.png" width="25%"/>

もしよかったら、遊んでみてくださいヽ(=´▽`=)ﾉ

要望・感想・アドバイスなどあれば、
公式アカウント([@MemoryLoverz](https://twitter.com/MemoryLoverz))や開発者([@kira_puka](https://twitter.com/kira_puka))まで♪