Vue3 のアプリにmavon-editorを用いてマークダウンフォームを導入する。

##mavon-editorとは
vue.jsのアプリに簡単にqiitaのようなマークダウン方式のフォームを導入できるライブラリです。

https://github.com/hinesboy/mavonEditor/blob/HEAD/README-EN.md

##導入する機能
・マークダウン編集
・編集内容をFirestoreに保存。（写真はfirebase storageに保存)
・投稿した写真を投稿内容埋め込む。
※写真を投稿する場合に癖があったので、記事にまとめました。

##環境
・Vue3
・compositionAPI
・mavon-editor 3.0.0-beta
・firestore v9
・firebase storage v9

##導入手順

Vue3用のmavon-editorをインストールする。

```
npm i mavon-editor@3.0.0-beta
```
https://www.npmjs.com/package/mavon-editor/v/next

### プラグインに追加する

```index.ts
import { createApp } from "vue";
import App from "./App.vue";
import mavonEditor from 'mavon-editor'
import 'mavon-editor/dist/css/index.css'

createApp(App).use(mavonEditor).mount("#app");
```
### 編集フォームを組み込む。

```vue
<template>
  <mavon-editor :toolbars="markdownOption" @imgAdd="imgAdd" language="en" v-model="content" />
  <button @click="register">登録</button>
</template>

<script setup lang="ts">
import 'mavon-editor/dist/css/index.css';
import markdownOption from "./markdownOption";

const imgAdd = () = {};
const register = () = {};

</script>
```
toolbarsプロパティーでエディターに表示する機能をカスタマイズできます。
↓詳細
https://github.com/hinesboy/mavonEditor/blob/HEAD/README-EN.md#props

@imgAddプロパティーで写真を追加した際に発火する関数を登録します。

language="en" でデフォルトの言語を設定します。(enで英語)

↓その他のevent詳細
https://github.com/hinesboy/mavonEditor/blob/HEAD/README-EN.md#events

### 内容を保存する関数を追加します。(register)

topicコレクションに、contentというフィールドをもつドキュメントを保存します。

```vue
<template>
  <mavon-editor :toolbars="markdownOption" @imgAdd="imgAdd" language="en" v-model="content" />
  <button @click="register">登録</button>
</template>

<script setup lang="ts">
import 'mavon-editor/dist/css/index.css'
import markdownOption from "./markdownOption";
import { db } from "../firebase/config"; //追加
import {doc,setDoc,} from 'firebase/firestore'; //追加 

const content = ref("");  //追加 

const register = async () => {  //追加 
    const newTopicRef = doc(collection(db, 'topic'));
    await setDoc(newTopicRef, {content:content.value});
  }
</script>
```

```firebase/config.ts
import {
  FirebaseOptions,
  getApps,
  getApp,
  initializeApp,
  FirebaseApp,
} from "firebase/app";

const firebaseConfig: FirebaseOptions = {
  apiKey: "xxxxxxxxxxxxxxxxxxxxx",
  authDomain: "xxxxxxxxxxxx.firebaseapp.com",
  projectId: "xxxxxxxxxxxx",
  storageBucket: "xxxxxxxxxxxx.appspot.com",
  messagingSenderId: "xxxxxxxxxxxx",
  appId: "1:xxxxxxxxxxxx:web:xxxxxxxxxxxx",
  measurementId: "xxxxxxxxxxxx",
};

const firebaseApp: FirebaseApp = !getApps().length
  ? initializeApp(firebaseConfig)
  : getApp();

import { getFirestore} from "firebase/firestore";
const db = getFirestore(firebaseApp);

export { firebaseApp ,db};
```

ここまでで、編集フォームからマークダウンのプレビューの確認、保存が可能となります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547556/035084cb-f10b-7d39-3789-aa45f21894f7.png)

###問題点
マークダウン形式の文字列で保存が可能ですが、写真を保存しようとした場合問題点があります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547556/a3bc320a-be4b-18e3-ec44-c939206e6baf.png)

上記のように  \!\[nuxt3sq.jpeg](1) という文字列で保存されます。
プレビューでは正しく表示されていますが、保存後、再度表示した場合は、 単純にファイル名の文字列として認識されるため当然正しく表示されません。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547556/11f0154e-efa6-c2d3-9583-0ba389a40055.png)

###写真を正しく保存するプロセス
1.フォーム上で写真を追加する。
2.追加した写真をFirebase storageにアップロードする。
3.アップロードのサーバーURLを取得する。
4.写真追加時に追加された文字列（\!\[nuxt3sq.jpeg](1))を以下の文字列で置き換える。
 `![image](サーバーのURL)`

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547556/2326347f-644c-1816-1e4e-e1e66294ad71.png)


#### 写真を追加した場合の実装を追加する。

```ts
<script setup lang="ts">
import 'mavon-editor/dist/css/index.css'
import markdownOption from "./markdownOption";
import { db } from "../firebase/config"; 
import {doc,setDoc,} from 'firebase/firestore';
import { getStorage, ref, uploadBytesResumable, getDownloadURL, deleteObject } from "firebase/storage"; //　追加
const storage = getStorage(); //追加

import { db } from "../firebase/config";
import { v4 as uuidv4 } from 'uuid'　//追加

const content = ref("");
const files=ref<FileInfo[]>([]); //追加 ※1

interface FileData { //追加
  file: File,
  content: string,
}

interface FileInfo { //追加
  id: string,
  url: string
}

const imgAdd = async (_: string, imgfile: File) => { //追加 ※2
  const fileData = {file: imgfile, content: content.value,}
  const {afterContent,afterFiles} = await uploadImg(fileData, files.value);
  content.value = afterContent;
  files.value = afterFiles;
};

const uploadImg = async(fileData: FileData, files: FileInfo[])=> { //追加　※3
    const id = uuidv4()
    const storageRef = ref(storage, `images/${id}`);
    const uploadTask = await uploadBytesResumable(storageRef, fileData.file);

    const url = await getDownloadURL(uploadTask.ref)
    const reg = new RegExp('!\\[.*\\]\\(\\d*\\)', 'g');
    const afterContent = fileData.content.replace(reg,  `![image](${url})`);
    const afterFiles = [...files, { id, url }]
    return { afterContent, afterFiles }
  }

const register = async (content: string,) => {  //追加 
    const newTopicRef = doc(collection(db, 'topic'));
    await setDoc(newTopicRef, {content});
  }
</script>
```

####解説

※1 画像ファイルのidとurlを格納するための配列。
※2 画像が追加された場合に発火する関数。
※3 実際に写真をアップロードする関数。
uploadBytesResumable関数でstorageに保存する。
getDownloadURLでサーバーURLを取得する。
下記記述で、写真追加時に書き込まれた文字列を、置き換えます。

```ts    
const reg = new RegExp('!\\[.*\\]\\(\\d*\\)', 'g');
const afterContent = fileData.content.replace(reg,  `![image](${url})`);
```
![54ddc9bfbe45feafdc849082a301818f.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547556/4f1ab153-e794-b571-058c-6749babaee35.gif)

これで、サーバーURLを文字列で保存することにより、再度表示した場合に正しく表示されるようになりました。

###改善点
このままでも良いのですが現状、写真を追加した時点で、storageに写真が保存されるため、その後の編集で結局写真のURLを削除した場合でもstorageに余計な写真ファイルが残ってしまします。
そこで、投稿登録時に写真を削除していた場合は、該当の写真ファイルをstorageから削除する実装を行います。

###storageから不要なファイルを削除するプロセス。
1.現状、追加されたファイルは以下のような型でfilesに格納されている。

```ts
[
 {id:保存時のファイル名,url:保存サーバーのURL},
 {id:保存時のファイル名,url:保存サーバーのURL}
]
```
2.登録ボタンが押された場合に、filesの各オブジェクトのurlが投稿の文字列に含まれているかチェック。

3.投稿内容の文字列にurlが含まれていない場合は、storageから削除

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547556/cc352248-3aed-3a88-bc2f-ada8f4f7cc94.png)

```ts
const _deleteImgFromStorage = (deletedFiles: FileInfo[]) => { //追加 ※2
  deletedFiles.forEach(file => {
    const deleteRef = ref(storage, `images/${file.id}`);
    deleteObject(deleteRef).then(() => {
    }).catch((error) => {
      console.log(error);
    });
  });
}

const _splitFiles = (files: FileInfo[], content: string) => { //追加 ※1 
  const existFiles: FileInfo[] = [];
  const deleteFiles: FileInfo[] = [];
  for (let i = 0; i < files.length; i++) {
    if (content.includes(files[i].url)) {
      existFiles.push(files[i])
    } else {
      deleteFiles.push(files[i])
    }
  }
  return { existFiles, deleteFiles }
};
const register = async () => { 
    const { existFiles, deleteFiles } = _splitFiles(files.value, content.value); //追加
    _deleteImgFromStorage(deleteFiles);
    const newTopicRef = doc(collection(db, 'topic'));
    await setDoc(newTopicRef, {content:content.value, files:existFiles});
  }
```

####解説
※1 投稿内容の文字列にurlが含まれているかチェックする関数。
※2 storageから削除する関数。

以上です。

###全文

```vuejs
<template>
  <mavon-editor :toolbars="markdownOption" @imgAdd="imgAdd" language="en" v-model="content" />
  <button @click="register">登録</button>
</template>

<script setup lang="ts">
import 'mavon-editor/dist/css/index.css'
import markdownOption from "./markdownOption";
import { db } from "../firebase/config"; 
import {doc,setDoc,} from 'firebase/firestore';
import { getStorage, ref, uploadBytesResumable, getDownloadURL, deleteObject } from "firebase/storage"; 
const storage = getStorage(); 

import { db } from "../firebase/config";
import { v4 as uuidv4 } from 'uuid'　

const content = ref("");
const files=ref<FileInfo[]>([]); 

interface FileData { 
  file: File,
  content: string,
}

interface FileInfo { 
  id: string,
  url: string
}

const imgAdd = async (_: string, imgfile: File) => { 
  const fileData = {file: imgfile, content: content.value,}
  const {afterContent,afterFiles} = await uploadImg(fileData, files.value);
  content.value = afterContent;
  files.value = afterFiles;
};

const uploadImg = async(fileData: FileData, files: FileInfo[])=> { 
    const id = uuidv4()
    const storageRef = ref(storage, `images/${id}`);
    const uploadTask = await uploadBytesResumable(storageRef, fileData.file);

    const url = await getDownloadURL(uploadTask.ref)
    const reg = new RegExp('!\\[.*\\]\\(\\d*\\)', 'g');
    const afterContent = fileData.content.replace(reg,  `![image](${url})`);
    const afterFiles = [...files, { id, url }]
    return { afterContent, afterFiles }
  }

const _deleteImgFromStorage = (deletedFiles: FileInfo[]) => { 
  deletedFiles.forEach(file => {
    const deleteRef = ref(storage, `images/${file.id}`);
    deleteObject(deleteRef).then(() => {
    }).catch((error) => {
      console.log(error);
    });
  });
}

const _splitFiles = (files: FileInfo[], content: string) => { 
  const existFiles: FileInfo[] = [];
  const deleteFiles: FileInfo[] = [];
  for (let i = 0; i < files.length; i++) {
    if (content.includes(files[i].url)) {
      existFiles.push(files[i])
    } else {
      deleteFiles.push(files[i])
    }
  }
  return { existFiles, deleteFiles }
};

const register = async () => { 
    const { existFiles, deleteFiles } = _splitFiles(files.value, content.value); 
    _deleteImgFromStorage(deleteFiles);
    const newTopicRef = doc(collection(db, 'topic'));
    await setDoc(newTopicRef, {content:content.value, files:existFiles});
  }
</script>
```

# xxs対策
mavon-editorはデフォルトで特定のスクリプトタグ
例:
`<script> <a>`等を表示させないようになっているが、下記のような記述を埋め込まれた場合、実行してしまう。

```
<a onmouseover=alert(document.cookie)>click me!</a>
```

![75bed888f2848d695a949503aea7231c.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/547556/19b21641-79cc-58ec-6e82-05e0d48fdadb.gif)

保存処理をかける前に、入力内容を下記の関数に通してサニタイズしておく。

```typescript
const escapeHTML = (word: string) => {
  return word.replace(/&/g, '&lt;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, "&#x27;");
}
```
ただし、このままだとコードブロック内の記号と画像URLに含まれる＆も置き換えてしまう。
そのための対策を行う。
続きは下記になります。

https://qiita.com/sho_U/items/aae93bfb455ee983f709

また、このままでは保存後の再編集時に置き換え後の文字列で表示されるため、編集の際は文字列を下記の関数に通す。

```javascript
const reSanitize = (sanitizedWord: string) => {
  return sanitizedWord.replace(/&lt;/g, '&')
    .replace(/&lt;/g, '<')
    .replace(/&gt;/g, '>')
    .replace(/&quot;/g, '"')
    .replace(/&#x27;/g, "'");
};
```

###参考にさせていただいた記事
https://qiita.com/nozomi_nozomi/items/a7f9f6c3890782a36d24