Open12

Vue3でMarkdownエディタを作るよ

えーはぶえーはぶ

環境

前提として下記の環境で開発を進める

  • JSフレームワーク:Vue3
  • スタイルフレームワーク:
    • Vuetify3
    • Tailwind3
えーはぶえーはぶ

候補パーサー

JSのMarkdownパーサーは主に下記2点

  • markdown-it
  • marked

両方とも触ってみた感じ特に大きな差異はないが、
markdown-itは調べるときにググりにくいのでmarkedにする

えーはぶえーはぶ

markedで簡単に実装

インストール

terminal
npm i marked

簡易的に実装

コンポーネント化してテキストエリアの内容をリアルタイムにレンダリングするようにします。

Editor.vue
import { Link, useForm, Head } from '@inertiajs/vue3';
import MarkDownViewer from '@/Components/MarkDownViewer.vue';

const form = useForm({
  body: ""
});

// 省略
<v-textarea variant="solo" flat placeholder="Write Me" :height="$vuetify.display.height - 320" auto-grow hide-details v-model="form.body" />
// 省略
<MarkDownViewer :source="form.body" />
// 省略
MarkDownViewer.vue
<script setup lang="ts">
import { watch, ref } from 'vue';
import { Marked } from "marked";

const props = defineProps({
    source: {
        type: String,
        required: true,
    },
});
const marked = new Marked();
const compiledMarkdown = ref("");
watch(() => props.source, () => {
    compiledMarkdown.value = marked.parse(props.source)
})
</script>

<template>
    <div v-html="compiledMarkdown"/>
</template>

試してみると、装飾がされない…

でもちゃんとheaderタグで出力されてるんでとりまヨシ!

えーはぶえーはぶ

非推奨オプションの修正

警告がめっちゃ出てます。

公式ドキュメントをみると非推奨の関数が結構あるので対応します。

警告で出てたライブラリをインストール

terminal
npm i marked-mangle marked-gfm-heading-id
<script setup lang="ts">
import { watch, ref } from 'vue';
import { Marked } from "marked";
import { mangle } from "marked-mangle";
import { gfmHeadingId } from "marked-gfm-heading-id";

const props = defineProps({
    source: {
        type: String,
        required: true,
    },
});
const marked = new Marked();
let options = {
    prefix: "my-prefix-",
};
marked.use(mangle())
    .use(gfmHeadingId(options))
    .use({ renderer });
const compiledMarkdown = ref("");
watch(() => props.source, () => {
    compiledMarkdown.value = marked.parse(props.source)
})
</script>

<template>
    <div v-html="compiledMarkdown"/>
</template>

警告が消えた!ヨシ!
またこのプラグインを入れたことで、Headerに自動的にidが振られ、またメールアドレスもマングリングされたのでヨシ!

えーはぶえーはぶ

レンダリング時にクラスを付与

装飾がされていないのでVuetifyのClassを付与して装飾する。
markedにはrendererというクラスがあり、それをオーバーライドすればできるそうなので、
公式ドキュメントくんを参考に実装。

const renderer = new marked.Renderer();
renderer.heading= function (text, level, raw, slugger){
      {
        let mapping = {
            1: 'text-h3',
            2: 'text-h4',
            3: 'text-h5',
            4: 'text-h6'
        }
        if (this.options.headerIds) {
            const id = this.options.headerPrefix + slugger.slug(raw);
            return `<h${level} id="${id}" class="${mapping[level]}">${text}</h${level}>\n`;
        }

        // ignore IDs
        return `<h${level} class="${mapping[level]}">${text}</h${level}>\n`;
    }
};
marked.use(mangle())
    .use(gfmHeadingId(options))
    .use({ renderer });

ちゃんとClassが付与されてるみたいなのでヨシ!

えーはぶえーはぶ

コードブロックを装飾

みんな大好きhilightjsでコードを装飾する

本体とプラグインをインストール

terminal
npm i highlight.js marked-highlight

インストールしたものとテーマをインポートしてMarkedの定義時に色々設定

import { markedHighlight } from "marked-highlight";
import hljs from 'highlight.js';
import 'highlight.js/styles/github-dark.css';

//const marked = new Marked(); 用済みじゃい
const marked = new Marked(
    markedHighlight({
        langPrefix: 'hljs language-',
        highlight(code, lang) {
            if ((typeof lang === "string" || lang instanceof String) && lang.includes(":")) {
                lang = lang.substring(0, lang.indexOf(':'));
            }
            const language = hljs.getLanguage(lang) ? lang : 'plaintext';
            return hljs.highlight(code, { language }).value;
        }
    })
);


ヨシ!

えーはぶえーはぶ

コードブロックを装飾

コードブロックをオサレにしたいのでTailwindのドキュメントページ(こんなの)をパクってオシャレにする。

まずはまんまパクってコードを書く

<div
    class="relative z-10 col-span-3 bg-slate-800 rounded-lg shadow-lg xl:ml-0 dark:shadow-none dark:ring-1 dark:ring-inset dark:ring-white/10">
    <div class="relative flex text-slate-400 text-xs leading-6">
        <div class="mt-2 flex-none text-sky-300 px-4 py-1 flex items-center">Terminal</div>
        <div class="flex-auto flex pt-2 rounded-tr-xl overflow-hidden">
            <div class="h-8 flex-auto -mr-px bg-slate-700/50 border border-slate-500/30 rounded-tl"></div>
        </div>
        <div class="absolute top-2 right-0 h-8 flex items-center pr-4">
            <div class="relative flex -mr-2">
                <button type="button" class="text-slate-500 hover:text-slate-400">
                    <svg fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"
                        stroke-linejoin="round" aria-hidden="true" class="w-8 h-8">
                        <path
                            d="M13 10.75h-1.25a2 2 0 0 0-2 2v8.5a2 2 0 0 0 2 2h8.5a2 2 0 0 0 2-2v-8.5a2 2 0 0 0-2-2H19">
                        </path>
                        <path
                            d="M18 12.25h-4a1 1 0 0 1-1-1v-1.5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v1.5a1 1 0 0 1-1 1ZM13.75 16.25h4.5M13.75 19.25h4.5">
                        </path>
                    </svg>
                </button>
            </div>
        </div>
    </div>
    <div class="relative">
        <pre class="text-sm leading-6 text-slate-50 ligatures-none overflow-auto p-4">
            <code class="language-php bg-slate-800">ここにコードを書く</code>
        </pre>
    </div>
</div>


ええ感じやね!

次にMarkedのコードレンダリング部分をオーバーライドして埋め込む。

renderer.code = function (code, infostring, escaped) {
    const filename = infostring.substring(infostring.indexOf(':')).replace(':','');
    const lang = (infostring || '').match(/\S*/)![0];
    
    if (this.options.highlight) {
        const out = this.options.highlight(code, lang);
        if (out != null && out !== code) {
            escaped = true;
            code = out;
        }
    }
    code = code.replace(/\n$/, '') + '\n';
    console.log(lang);
    if (!lang) {
        return '<div class="relative z-10 col-span-3 bg-slate-800 rounded-lg shadow-lg xl:ml-0 dark:shadow-none dark:ring-1 dark:ring-inset dark:ring-white/10"><div class="relative flex text-slate-400 text-xs leading-6"><div class="flex-auto flex pt-2 rounded-tr-xl overflow-hidden"><div class="h-8 flex-auto -mr-px bg-slate-700/50 border border-slate-500/30 rounded-tl"></div></div><div class="absolute top-2 right-0 h-8 flex items-center pr-4"><div class="relative flex -mr-2"><button type="button" class="text-slate-500 hover:text-slate-400"><svg fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"stroke-linejoin="round" aria-hidden="true" class="w-8 h-8"><path d="M13 10.75h-1.25a2 2 0 0 0-2 2v8.5a2 2 0 0 0 2 2h8.5a2 2 0 0 0 2-2v-8.5a2 2 0 0 0-2-2H19"></path><path d="M18 12.25h-4a1 1 0 0 1-1-1v-1.5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v1.5a1 1 0 0 1-1 1ZM13.75 16.25h4.5M13.75 19.25h4.5"></path></svg></button></div></div></div><div class="relative"><pre class="text-sm leading-6 text-slate-50 ligatures-none overflow-auto p-4"><code class="language-php bg-slate-800">'
            + code
            + '</code></pre></div></div>\n';
    }
    const tab = filename == '' ? lang:filename;

    return '<div class="relative z-10 col-span-3 bg-slate-800 rounded-lg shadow-lg xl:ml-0 dark:shadow-none dark:ring-1 dark:ring-inset dark:ring-white/10"><div class="relative flex text-slate-400 text-xs leading-6"><div class="mt-2 flex-none text-sky-300 px-4 py-1 flex items-center '
        + this.options.langPrefix
        + tab
        + '">'
        + tab
        + '</div><div class="flex-auto flex pt-2 rounded-tr-xl overflow-hidden"><div class="h-8 flex-auto -mr-px bg-slate-700/50 border border-slate-500/30 rounded-tl"></div></div><div class="absolute top-2 right-0 h-8 flex items-center pr-4"><div class="relative flex -mr-2"><button type="button" class="text-slate-500 hover:text-slate-400"><svg fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="w-8 h-8"><path d="M13 10.75h-1.25a2 2 0 0 0-2 2v8.5a2 2 0 0 0 2 2h8.5a2 2 0 0 0 2-2v-8.5a2 2 0 0 0-2-2H19"></path><path d="M18 12.25h-4a1 1 0 0 1-1-1v-1.5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v1.5a1 1 0 0 1-1 1ZM13.75 16.25h4.5M13.75 19.25h4.5"></path></svg></button></div></div></div><div class="relative"><pre class="text-sm leading-6 text-slate-50 ligatures-none overflow-auto p-4"><code class="language-php bg-slate-800">'
        + code
        + '</code></pre></div></div>\n';
}

確認してみよう

コピー機能はとりあえず置いといて、ヨシ!

えーはぶえーはぶ

スタイルを調整

VuetifyがデフォルトのHTMLスタイルをリセットしてしまってるので箇条書きや太字など色々調整していきます。

とりあえずMarkedのサンプルを入れて確認します。
今の出力結果:

Markedの出力結果:

とりあえずリンクと箇条書き、引用を直したい。めんどくさい。

えーはぶえーはぶ

リンクの表示を修正

いつもどおりrendererからリンクの表示関数linkを探してオーバーライドする。

renderer.link = function (href, title , text) {
    href = cleanUrl(this.options.sanitize, this.options.baseUrl, href) as any;
    if (href === null) {
        return text;
    }
    let out = '<a href="' + href + '"';
    if (title) {
        out += ' title="' + title + '"';
    }
    out += '>' + text + '</a>';
    return out;
}

Marked.js内のヘルパー関数を使ってるっぽい…

うーん…この関数を独自に書くのも微妙だしできれば内部処理はあんま変えたくない…
やりたくなかったけどCSS書く…カァッ!

えーはぶえーはぶ

CSS書く…カァッ!

ここをこうして

<template>
    <div v-html="compiledMarkdown" class="markdown-text"/>
</template>

<style lang="scss" deep>
.v-application .markdown-text {
    a {
        color: #0f83fd;
    }
    a:hover{
        text-decoration: underline;
    }

}
</style>

こう!!!

ヨシ!

えーはぶえーはぶ

箇条書きを書く

いつも通りRendererをオーバーライドしてTailwindのクラスを追加して

renderer.list = function (body, ordered, start) {
    const type = ordered ? 'ol' : 'ul',
        startatt = (ordered && start !== 1) ? (' start="' + start + '"') : '';
    const list_class = ordered ? 'list-decimal':'list-disc';
    return '<' + type + startatt + ' class="'+list_class+' pl-10 py-4">\n' + body + '</' + type + '>\n';
}

こう!!!

えーはぶえーはぶ

引用文を装飾

下記のようにもにょもにょして

renderer.blockquote=function(quote) {
    return `<blockquote class="p-4 my-4 bg-gray-50 border-l-4 border-gray-400 rounded-md">\n${quote}</blockquote>\n`;
}

こう!