ブログじゃないもん

Nuxt+Markdownを独自拡張で使いやすくする

11分くらいで読める

Markdownは自由度が少ないのが長所で短所

このブログ、原稿はMarkdownファイルで執筆・管理し、それを読み込んでNuxt.jsでWebページを生成しています。

Markdownの良いところは自由度が少ないところ。

決まったルールの中で書くから様々なシステムとの連携がしやすく、汎用性を保ったまま文章コンテンツを作ることができます。

しかしその分、表現力に乏しいという弱点も。

 

たとえば画像を入れたいとき、通常のMarkdown記法だと

![ハンバーグ](hamburg.jpg)

みたいに書きますが、AMP対応とかのためにWidthとHeightも入れときたいとなるとMarkdownでごちゃごちゃやるより

<img src="hamburg.jpg" alt="ハンバーグ" width="200" height="200">

とかしたほうがいいのでは。

さらにキャプションも入れたいな、となるとmarkdown-it-divのプラグイン入れてdivで囲ってclass付けて

::: image
<img src="hamburg.jpg" alt="ハンバーグ" width="200" height="200">

肉汁がたまりません!
:::

……ってこれもうマークアップやん!

やりたいことが増えるたびに汎用性の高いクリーンな原稿からどんどん離れていってしまいます。

 

さっきの markdown-it-div みたいなプラグインを入れて拡張していくのが基本の考え方だと思いますが、痒いところに手が届くプラグインが見つからなかったりします。

そんなときは自分で記法を拡張して、HTMLに変換するロジックを噛ませてもいいかも。

markdown-it の前後に変換を噛ませる

通常のmarkdown-itの読み込みであればこんな感じ。

// /post/_slug.vue
<main v-html="$md.render(postdata.bodyContent)" />

これに変換を噛ませたいので、

まずはmarkdown-custom.jsみたいなファイル名でプラグインファイルを作ります。

// /plugins/markdown-custom.js
const markdownCustomAfter = (md) =>{
    // 改行を開ける
    md = md.replace(/<p>---<\/p>/g,
        '<p>&nbsp;</p>')
    return md
}
export default ({}, inject) => {
    inject('markdownCustomAfter', markdownCustomAfter)
}

ただでさえ記事ページの設定は長くなりがちな上、今回の変換も細かくやるとどんどん長くなっていくのでプラグインにしました。

これをnuxt.config.jsplugins:で読み込みます。

// nuxt.config.js
export default {
    plugins: [
        '~/plugins/markdown-custom'
    ]
}

そうするとinjectした関数を$markdownCustomAfter()と、最初に $ を付けた形でどこでも使えるようになるので、先ほどの$md.renderを引数にして v-html の中に入れます。

// /post/_slug.vue
<main v-html="
    $markdownCustomAfter(
        $md.render( postdata.bodyContent )
    )" />

markdown-it のレンダリングの前にも変換を入れたい場合は、さっき作ったmarkdown-custom.jsのプラグインにmarkdownCustomBefore()みたいな関数を追加して、$md.renderがかかる前に入れます。

// /post/_slug.vue
<main v-html="
    $markdownCustomAfter(
        $md.render(
            $markdownCustomBefore(
                postdata.bodyContent
            )
        ),
        postdata.slug // 追加の引数も入れられる
    )" />

こうやってmarkdown-itの前後に変換を入れると、かなりいろんなことができるようになります。

実際に使っている独自拡張

このように得た自由を使って、実際に採用した拡張記法は以下のとおり。

画像キャプション

先ほど例で挙げた画像キャプションについては、このように画像のあとに続けて書いた文章はキャプションになるようにしました。

<img src="hamburg.jpg" alt="ハンバーグ" width="200" height="200">
肉汁がたまりません!

直接変換するとキャプション内に入れたリンクがMarkdown記法のまま出力されてしまったので、トリッキーですがこのように前後に2段階で変換を入れています。

$md.render前に

const markdownCustomBefore = (md) =>{
    md = md.replace(
        /(<img [^>]+)\n([\S][\s\S]*?)\n\n/g,
        ':::\n$1\n\n$2\n:::\n\n'
    )
    return md
}

$md.render後に

const markdownCustomAfter = (md) =>{
    md = md.replace(
        /<div class="">\n<img (.*?)>\n+([\s\S]+?)\n<\/div>/g,
        '<figure class="img"><img $1><figcaption>$2</figcaption></figure>'
    )
    return md
}

なにをしているかというと、Markdownがレンダリングされる前にいったん前後に:::を入れてmarkdown-it-divの効果で画像とキャプションが<div>で囲まれるようにしつつ、キャプションの前にも改行を追加してキャプションが<p>で囲まれるように(段落だとリンクがちゃんとリンクの形にレンダリングされるようなので)。

$md.renderを通した後に改めてこのブロックを、<figure><figcaption>で清書しているようなイメージです。

変換が2回になるので管理に注意が必要ですが、こうしておくとキャプション部分のリンクや装飾は本来のエンジンに任せられます。

空行

通常、↓の3行は全て罫線(<hr>)に変換されます。

***

___ (アンダースコア×3)

--- (ハイフン×3)

この中でハイフン×3のパターンは空行(<p>&nbsp;</p>)になるようにしました。

$md.render後だとどれも<hr>になってしまうため、これも前に変換を入れています。

const markdownCustomBefore = (md) =>{
    md = md.replace(/\n\n(---)\n\n/g,
        '\n\n<p>&nbsp;</p>\n\n')
    return md
}

マークアップ的には<hr>のままでvisibility:hiddenとかにするほうが正しそうですが、そこでわざわざclassを追加するのもだるいかな、と。

しかしやっぱり<hr>がいいな、となった場合でも、このように原稿とページ生成をしっかり分けていれば原稿は修正することなく、このプラグインとかVueを変更して生成し直せば済みます。

バレット記号なしリスト

通常、↓の3パターンはどれも番号なしリスト<ul> <li>に変換されます。

- ハイフンでリスト
- ハイフンでリスト

+ プラスでリスト
+ プラスでリスト

* アスタリスクでリスト
* アスタリスクでリスト

この中でハイフンのパターンのみ、classを追加して各リスト項目の先頭につくバレット記号(「・」のような)が表示されないようにしました。

これも$md.render後だと違いがなくなるため、前に↓の変換を行っています。

const markdownCustomBefore = (md) =>{
    md = md.replace(/\n\n(- [\s\S]+?)\n\n/g,
        '\n\n::: nobullet\n$1\n:::\n\n')
    return md
}

これも<hr>と同様、前後に:::を入れて<div>で囲っていますが、最初の:::::: nobulletとしておくことで<div class="nobullet">になります。

あとはCSSのほうで

.nobullet ul{
    list-style:none;
    padding-left:0;
}

としてバレット記号なしで表示させています。

Twitter、YouTube貼り付け

TwitterとYouTubeの貼り付けもいろいろプラグインが出回っていますが、統一フォーマットでできるよう、それぞれのコンテンツIDを使ってこんな感じで貼り付けられるようにしました。

[[twitter:1172394707520933888]]
[[youtube:RxHbGPA9DWk]]

markdown-it-table-of-contentsのプラグインで[[TOC]]と書くと目次が出るようにしているので、その書き方にならった記法にしています。

さらにYouTubeのほうは画像と同じく、改行後に続けて文章を入れればキャプションになるようにしました。

[[youtube:RxHbGPA9DWk]]
特に15:55からが見どころ!

商品情報

参考文献のリンクなどを、こんな感じで記事に入れています。

これを表現するのに、以前は

::: product
::: img
<img src="https://assets.notblog.jp/post/add-extension-to-markdown-with-nuxt/hyoshi.jpg" width="200" height="300">
:::

### ブルバスター 02

::: setsumei
小型巨獣の捕獲に成功した沖野たちは巨獣出現メカニズムの解明を期待してシオタバイオを訪れるも、門前払いに等しい扱いを受けてしまう。
一方、波止工業に新たな新人がやってくることに。
:::
:::

みたいにほぼマークアップやんけ!なMarkdown原稿に、上で書いてきたような変換を噛ませてデザインしてました。

しかしデザイン組み変えの際に超絶めんどくさかったので、いっそのこと…と別ファイルで管理することにして、

[[product:4047357162]]

と書くと読み込まれるようにしました。

product:のあとのIDはAmazonのASINコード。これをIDとして商品ごとにMarkdownファイルを作って、記事と同じように読み込んで正規表現で整形しています。

const markdownCustomAfter = (md) =>{
    md = md.replace(
        /<p>\[\[product:([A-Z0-9]+)\]\]<\/p>/g,
        (match, asin) => {
            const product = products[asin]
            return '<section class="product">'
                + '<header>'
                    + '<img src="./' + product.asin + '.jpg">'
                    + '<h3>' + product.title + '</h3>'
                + '</header>'
                + '<div>' + product.description + '</div>'
            + '</section>'
        }
    )
    return md
}

replace()の第2引数に関数を指定できるの、めちゃ便利ですね!