Edited at

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

ほそぼそと作っているWebサービスの積読ハウマッチ

Gigazineさんに紹介されました🎉🎉🎉

おかげさまでユーザやアクセスが増えたのですが、

問い合わせやコメントも急増したので、FAQを用意したほうがいいなと...

Nuxt.jsのページのうち、FAQのような静的なページだけ、

Markdownで作成できないかを調査したときの備忘録。

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


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


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

  2. Markdown内でVueコンポーネントを使う凝った方法

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

これを使うと一部のページをマークダウンで書けるので、

FAQとか以外にブログとか記事とかも簡単にmarkdownで書けるようになるかもと

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



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

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

# よくある質問 / FAQ

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

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

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

こんな感じの画面を作れちゃいます!


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

ツイートを埋め込めるよう、Markdownファイルの中で

vue-tweet-embedを利用しています。

# 更新情報

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

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

1.の方法だと<Tweet>タグがそのままのため、なにも表示されませんが、

2.の方法だと、こんな感じの画面を作れちゃいます!



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


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

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


  1. Markdownファイルを読み込んで、

  2. markedでHTML化して、

  3. v-htmlでHTMLを挿入

Nuxt.jsでは、Markdownファイル(.md)のようなファイルを

そのまま取り込む方法が無いので、

Webpackプラグインのraw-loaderを使います。


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

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

$ npm install --save raw-loader marked highlightjs


nuxt.config.jsの設定

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

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ファイルを読み込んでページが作れちゃいます!

あとは読み込むファイルを変えるだけで、簡単に静的なページを量産(´ω`)

<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がいいかは、前の記事を参照ください。


注意: ハマった点...

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-*属性が付与されますが、

v-htmlでHTMLを挿入しているため、その属性がついていません。。

これにより、Scoped CSSが効かないため、

markdown用のCSSを利用する際はグローバルで設定する必要があります。

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



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

1.の方法で、ツイートを埋め込んでみようと思ったらうまく行かず...

vueでツイートを埋め込むには、vue-tweet-embedが必要なようで、

以下のようにしてみたら、

# 更新情報

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

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

こんな感じに...

ツイッターで、

と悩んでたところ、@mizuki_rさんから、こんなアドバイスが(´ω`)


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

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


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


大まかな流れ



  1. カスタムディレクティブを用意

  2. カスタムディレクティブ内でコンポーネントを作成し、

  3. コンポーネントのtemplateにmarked()のHTMLを渡して、

  4. Vueのコンパイル(=$mounte()の呼び出し)

  5. Vueのコンパイルした結果からHTML要素を取得($el)して、

  6. カスタムディレクティブを設定したHTML要素の子に挿入(el.appendChild(instance.$el))

長いし、複雑...


.vueはこんな感じ

.vue自体はほぼ同じ。

v-htmlがカスタムディレクティブのv-mdに変わってるだけ。

<template>

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

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


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

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

$ npm install --save vue-tweet-embed


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

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

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に設定を追加

import NuxtConfiguration from "@nuxt/config";

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


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

まっしろ(´ω`)

タイトルすら出ない...

Consoleを見るとエラーが...


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の設定を変更していく。


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

公式ドキュメントの「さまざまなビルドについて」を見ると、

vue/dist/vue.commonを使えば良さそう。

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

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";
}
}
},
}

すると...

出た(´ω`)!!


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

1点対応していない部分が...

Markdownファイル内で<nuxt-link>を使うと、エラーが...


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


Nuxt側からcontextやvue-routerの情報を渡していないためだと思いますが、

詳しく見れていませんが、nuxt-linkを使うとエラーになるため、ご注意ください...


追記: 2019/09/13

Vue Routerの公式ドキュメントを見てたら、ちゃんと書いてあった。。

Nuxt.jsのContextにあるrouterをわたして、InjectしてあげればOK!

なので、カスタムディレクティブでrouterを渡すように変更していく

それぞれこんな感じに。


.vueファイル

<template>

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


カスタムディレクティブ

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);
}
});


おわりに

marked+highlightjs+raw-loaderを使って、

Markdownファイルでページコンテンツを作れるように!

カスタムディレクティブを使うと、

Markdownファイル内でVueコンポーネントを使えるように!

今回のアップデートでFAQや更新情報のページに利用してますが、

サイトの説明や使い方ページなどにも使えたり、

Nuxt.jsアプリにmarkdownで書いた記事なども簡単にできそう(´ω`)


こんなのつくってます!!

上記を使ってFQAや更新情報ページを追加した、積読用の読書管理アプリ「積読ハウマッチ」!

積読ハウマッチはNuxt.js(SPA)+Firebaseで開発してます!

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

要望・感想・アドバイスなどあれば、

公式アカウント(@MemoryLoverz)や開発者(@kira_puka)まで♪