シンプルブログ、マークダウン対応

Vue.js

概要

DRFとVue.jsで、シンプルブログを作るシリーズの一つです。APIから返される本文を加工するで、記事の本文をHTMLへ変換するようにしました。改行が二つあればそれは段落のp要素となり、URLの場合はa要素、URLで画像っぽい場合はimg要素、といったように実装しました。

とはいえ、これだけだと凝った本文が書けません。例えば、見出し等が作れません。なので、今回は記事の本文をマークダウンで書けるようにしていきます。

準備

今回もサーバー側でHTMLに変換してから、Vue.js側に返すように実装します。そのために必要なライブラリをインストールしておきましょう。

pip install markdown

シリアライザーの設定

シリアライザーでやっていた変換処理を、マークダウンテキストを変換するように書き換えます。

import markdown  # 追加


class PostSerializer(serializers.ModelSerializer):
    category = CategorySerializer(read_only=True)
    main_text = serializers.SerializerMethodField()

    class Meta:
        model = Post
        fields = '__all__'

    # 変更
    def get_main_text(self, instance):
        return markdown.markdown(instance.main_text, extensions=['markdown.extensions.toc'])

markdown.markdownで変換しています。これで## ハローのような部分は<h2>ハロー</h2>に変換されますし、[リンクA](https://narito.ninja/)<a href="https://narito.ninja/">リンクA</a>になります。

extensions=['markdown.extensions.toc']は、目次を作る拡張機能を有効にしています。

Vue側の設定

このままでも問題ないのですが、せっかくなので見た目も少し変更します。詳細ページの左側に目次が表示されて、右側に記事本文が表示されるような、よく見るレイアウトにしていきます。次の画像のような感じです。

テンプレート部分を次のようにします。

<template>
    <article :key="id" class="container" v-if="post">
        <header>
            <nav id="back"><a @click="goBack" title="前ページへ戻る"><img src="@/assets/back.png"></a></nav>
            <p class="post-category" :style="{'color': post.category.color}">{{post.category.name}}</p>
            <h1 class="post-title">{{post.title}}</h1>
        </header>
        <div id="main">
            <nav id="toc" ref="toc"></nav>
            <div id="post-main" ref="text" v-html="post.main_text"></div>
        </div>
        <hr class="divider">
        <nav id="top"><a @click="scrollTop" title="一番上まで戻る"><img src="@/assets/ue.png"></a></nav>
    </article>
</template>

<div id="main">をつくり、その中に目次エリアと、本文エリアを作りました。

スタイルは次のようにしておきます。

<style scoped>
    header {
        margin-bottom: 80px;
    }

    #back {
        margin-bottom: 80px;
    }

    #back a {
        cursor: pointer;
        width: 44px;
        display: inline-block;
    }

    #top a {
        cursor: pointer;
        color: #999;
        display: inline-block;
        width: 44px;
    }

    .post-category {
        font-size: 20px;
    }

    .post-title {
        font-weight: bold;
        font-size: 28px;
    }

    .divider {
        margin-top: 80px;
        margin-bottom: 80px;
        width: 100%;
        height: 1px;
        border: none;
        background-color: #ccc;
    }

    #post-main {
        width: 100%;
        line-height: 2;
    }

    #main >>> div.toc a {
        font-size: 12px;
        color: #666666;
        text-decoration: none;
    }

    #post-main >>> > * {
        margin-bottom: 1.5em;
    }

    #post-main >>> div.toc + * {
        margin-top: 0;
    }

    #post-main >>> > h2 {
        font-weight: bold;
        font-size: 20px;
        line-height: 1.5;
        margin-top: 50px;
        margin-bottom: 21px;
    }

    #post-main >>> img {
        max-width: 100%;
        height: auto;
        box-shadow: 0 0 5px #ccc;
    }

    #post-main >>> strong {
        background-color: yellow;
    }

    #post-main >>> div.toc {
        display: none;
    }

    #toc {
        display: none;
    }

    @media (min-width: 768px) {

        #post-main {
            width: 650px;
        }
    }

    @media (min-width: 1024px) {
        #main {
            display: grid;
            grid-template-columns: 150px 650px;
            column-gap: 50px
        }

        #toc {
            grid-column: 1;
            display: block;
            align-self: start;
            position: sticky;
            top: 20px;
        }

        #post-main {
            grid-column: 2;
        }

    }
</style>

最後にscript部分です。

<script>
    export default {
        name: 'post',
        props: {
            id: {type: Number},
        },
        data() {
            return {
                post: null,
                hasBefore: false,
            }
        },
        beforeRouteEnter(to, from, next) {
            next(component => {
                if (from.name) {
                    component.hasBefore = true
                }
            })
        },
        mounted() {
            this.$http(`${this.$httpPosts}${this.id}/`)
                .then(response => {
                    return response.json()
                })
                .then(data => {
                    this.post = data
                    document.title = `${data.title} - Design Note`
                    document.querySelector('meta[name="description"]').setAttribute('content', data.lead_text)
                    this.$nextTick(() => this.moveToc())
                })
        },
        methods: {
            goBack() {
                if (this.hasBefore) {
                    this.$router.go(-1)
                } else {
                    this.$router.push({name: 'posts'})
                }
            },
            scrollTop() {
                window.scrollTo({
                    top: 0,
                    behavior: "smooth"
                });
            },
            moveToc() {
                const innerToc = this.$refs.text.querySelector('div.toc')
                const cloneToc = innerToc.cloneNode(true)
                this.$refs.toc.appendChild(cloneToc)
            }
        }
    }
</script>

mounted内でのAPIとのやり取りの後に、this.$nextTick(() => this.moveToc())というコードが増えました。何をしているか説明する前に、APIから返される内容を説明します。次のようなHTMLです。

<div class="toc">
  <ul>
    <li>
      <a href="#_1">見出しA</a>
    </li>
  </ul>
</div>

<h2 id="#_1">見出しA</h2>
<p>段落1</p>

今回は目次を使っていて、それは<div class="toc">部分ですね。その後に見出しや段落などが続くのですが、今回は見出しと文章を別々の場所に配置したいのです。なので、div.toc部分をコピーなり切り取りなりして、本来の配置したい場所に移す必要があります。それをしているので、moveTocメソッドです。

APIから返却されたHTMLはthis.post = dataとしてdataオプションのpostプロパティ代入されているので、postプロパティを参照している<div id="post-main" ref="text" v-html="post.main_text"></div>部分に反映されますが、これはすぐに反映されるわけではなく、少し時間差があります。なので、描画が終わってからmoveTocメソッドを呼ぶ必要があります。this.$nextTick(()はそのための処理です。

moveTocでは、要素をコピーして、本来の目次配置場所に張り付けるということをしています。this.$refs.textのように書いていますが、これは<div id="post-main" ref="text" v-html="post.main_text"></div>と対応しています。Vueではclassやidをできるだけ使わずに、refを使って要素の取得等をします。

Relation Posts

django-markdownxの紹介

django-markdownを使い、DjangoアプリケーションにMarkdownエディタを導入していくサンプルです。

Python Django Djangoライブラリ Markdown

Comment

記事にコメントする

まだコメントはありません。