50行未満で実装！Stimulusを使ったMarkdownのプレビュー機能

## はじめに

先日開催された[Hotwire.love Vol.42](https://hotwire-love.connpass.com/event/351726/)で、以下のようなMarkdownのプレビュー機能を実装してみました。
（Previewにチェックを入れると、テキストエリアに書いたMarkdownがHTMLとしてプレビュー表示される）

![markdown.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7465/ed4d0457-0fec-43a4-84ed-1e3aba3ab43c.gif)

Stimulusを使うとわずか50行未満で実装できたので、この記事ではその方法を紹介します。

### 動作確認したバージョン

この記事で使った各ライブラリのバージョンは以下の通りです。

- rails 8.0.2
- stimulus 3.2.2 (stimulus-rails 1.3.4)
- marked 15.0.11
- dompurify 3.2.5

また、コードは以下のGitHubリポジトリで公開しています。

https://github.com/JunichiIto/markdown-preview-sandbox

## サンプルアプリの仕様（変更前）

ここではごく簡単なブログアプリをサンプルアプリとして使用します。

よくあるScaffoldを使ったRailsアプリケーションです。

### 編集画面
![Screenshot 2025-05-17 at 17.40.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7465/9481592d-9842-43bf-beb3-e508f08e4a88.png)

### 詳細画面
![Screenshot 2025-05-17 at 17.40.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7465/e22be551-ce83-4167-8545-d0c5ca6bf831.png)

今からこのアプリケーションにMarkdownのプレビュー機能を実装していきます。

## 実装手順

それではプレビュー機能の実装手順を説明します。

### 1. Markdown用のnpmパッケージをインストール
今回、MarkdownからHTMLへの変換処理はクライアントサイド（ブラウザ内）で実行します。

そのために[marked](https://www.npmjs.com/package/marked)を使用します。
また、生成されたHTMLをサニタイズするために[dompurify](https://www.npmjs.com/package/dompurify)も使います。

以下のコマンドで2つのnpmパッケージをインストールします。

```
bin/importmap pin marked
bin/importmap pin dompurify
```

yarnを使っている場合は`yarn add`コマンドを使ってください。

```
yarn add marked dompurify
```

### 2. Markdown用のStimulusコントローラを生成

次に以下のコマンドでMarkdown用のStimulusコントローラを生成します。

```
rails g stimulus markdown
```

すると`app/javascript/controllers/markdown_controller.js`に以下のファイルが生成されます。

```javascript
import { Controller } from "@hotwired/stimulus"

// Connects to data-controller="markdown"
export default class extends Controller {
  connect() {
  }
}
```

### 3. プレビューを切り替えるためのチェックボックスを追加

`app/views/blogs/_form.html.erb`にプレビューを切り替えるためのチェックボックスを追加します。

```diff_erb
 <div class="mb-3">
   <%= form.label :content, class: "form-label" %>
+  <label class="ms-2">
+    <input type="checkbox">
+    Preview
+  </label>
   <%= form.text_area :content, rows: 10, class: "form-control" %>
</div>
```

以下のようにチェックボックスが表示されます。

![Screenshot 2025-05-17 at 17.54.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7465/d409b13d-54d5-4ec5-8257-d0623cfd3a13.png)

### 4. Stimulusと連携させるためにHTMLに属性を追加する

続いて、Stimulusと連携させるためにHTML（ERB）に以下のような属性を追加します。

```diff_erb 
-<div class="mb-3">
+<div class="mb-3" data-controller="markdown">
   <%= form.label :content, class: "form-label" %>
   <label class="ms-2">
     <input type="checkbox">
     Preview
   </label>
-  <%= form.text_area :content, rows: 10, class: "form-control" %>
+  <%= form.text_area :content, rows: 10, class: "form-control",
+    data: { markdown_target: "editor", action: "markdown#change" } %>
+
+  <div data-markdown-target="preview"></div>
 </div>
```

各属性の意味や役割はHTMLコメントを参考にしてください。

```erb 
<!-- div内を markdown_controller の制御下におく -->
<div class="mb-3" data-controller="markdown">
  <%= form.label :content, class: "form-label" %>
  <label class="ms-2">
    <input type="checkbox">
    Preview
  </label>
  <!-- markdown_controller内ではeditorTargetという名前でテキストエリアを参照する -->
  <!-- テキストエリアの内容が変更されたらmarkdown_controllerのchangeメソッドを呼び出す -->
  <%= form.text_area :content, rows: 10, class: "form-control",
    data: { markdown_target: "editor", action: "markdown#change" } %>

  <!-- Markdownのプレビューをこのdiv内に表示する -->
  <!-- markdown_controller内ではpreviewTargetという名前でこのdivを参照する -->
  <div data-markdown-target="preview"></div>
</div>
```

### 5. マークダウンを（リアルタイムに）プレビューできるようにする

`app/javascript/controllers/markdown_controller.js`に以下のようなコードを書きます。

コードの意味はコメントを参考にしてください。

```javascript
import { Controller } from "@hotwired/stimulus"
import { marked } from "marked";
import DOMPurify from "dompurify";

// Connects to data-controller="markdown"
export default class extends Controller {
  // editorはテキストエリア、previewは上で追加したdiv
  static targets = ["editor", "preview"]

  connect() {
  }

  // テキストエリアの内容が変更されたときに呼ばれる
  change() {
    // テキストエリアの内容を取得
    const markdown = this.editorTarget.value
    // markedでmarkdownをHTMLに変換し、さらにDOMPurifyでサニタイズ
    this.previewTarget.innerHTML = DOMPurify.sanitize(marked.parse(markdown))
  }
}
```

こうすると、以下のようにテキストエリアに入力した内容に応じて、リアルタイムにプレビューが更新されます。

![May-17-2025 18-16-17.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7465/b1044558-c77a-45d8-97af-079e87b0b9e1.gif)

「あれっ、チェックボックスでプレビューを切り替えるんじゃないの？」と思った方へ。

安心してください。それは次のステップで実装します。

この時点ではいったんテキストエリアの入力とマークダウンのプレビューが連動していることを確認しました。

### 6. プレビュー表示を切り替えるためにHTML属性を追加する

チェックボックスを使ってプレビュー表示を切り替えるために、以下のようなHTML属性を付与します。

```diff_erb 
 <div class="mb-3" data-controller="markdown">
   <%= form.label :content, class: "form-label" %>
   <label class="ms-2">
-    <input type="checkbox">
+    <input type="checkbox"
+      data-markdown-target="previewSwitch" data-action="markdown#togglePreview">
     Preview
   </label>
   <%= form.text_area :content, rows: 10, class: "form-control",
-     data: { markdown_target: "editor", action: "markdown#change" } %>
+     data: { markdown_target: "editor" } %>
 
-  <div data-markdown-target="preview"></div>
+  <div data-markdown-target="preview" class="d-none"></div>
 </div>
```

付与した属性の意味は以下のコメントの通りです。
なお、このサンプルアプリケーションではCSSとして[Bootstrap](https://getbootstrap.jp/)を使っています。
"d-none"は指定した要素を非表示にするためのBootstrapのクラス名です。

```erb 
<div class="mb-3" data-controller="markdown">
  <%= form.label :content, class: "form-label" %>
  <label class="ms-2">
    <!-- markdown_controller内ではpreviewSwitchTargetという名前でcheckboxを参照する -->
    <!-- チェックボックスのチェックが切り替わったらmarkdown_controllerのtogglePreviewメソッドを呼び出す -->
    <input type="checkbox"
      data-markdown-target="previewSwitch" data-action="markdown#togglePreview">
    Preview
  </label>
  <!-- リアルタイム変換は不要なのでactionは削除 -->
  <%= form.text_area :content, rows: 10, class: "form-control",
     data: { markdown_target: "editor" } %>

  <!-- d-noneを付与して初期状態ではプレビューを非表示にする -->
  <div data-markdown-target="preview" class="d-none"></div>
</div>
```

### 7. Stimulusでプレビューを切り替えられるようにする

次に、Stimulusでプレビューを切り替えられるようにします。

変更点は以下の通りです。
```diff_javascript
 export default class extends Controller {
-  static targets = ["editor", "preview"]
+  static targets = ["editor", "preview", "previewSwitch"]
 
   connect() {
   }
 
-  change() {
-    const markdown = this.editorTarget.value
-    this.previewTarget.innerHTML = DOMPurify.sanitize(marked.parse(markdown))
-  }
+  togglePreview() {
+    const isPreviewMode = this.previewSwitchTarget.checked
+    if (isPreviewMode) {
+      const markdown = this.editorTarget.value
+      this.previewTarget.innerHTML = DOMPurify.sanitize(marked.parse(markdown))
+    }
+    this.editorTarget.classList.toggle("d-none", isPreviewMode)
+    this.previewTarget.classList.toggle("d-none", !isPreviewMode)
+  }
 }
```

各コードの意味は以下のコメントを参考にしてください。

```javascript
export default class extends Controller {
  // previewSwitchはプレビュー切り替え用のチェックボックス
  static targets = ["editor", "preview", "previewSwitch"]

  connect() {
  }

  // プレビュー切り替え用のチェックボックスが変更されたときに呼ばれる
  togglePreview() {
    // チェックボックスにチェックが入っていたらプレビューモード
    const isPreviewMode = this.previewSwitchTarget.checked
    if (isPreviewMode) {
      // プレビューモードに切り替わるタイミングでMarkdownをHTMLに変換して表示
      const markdown = this.editorTarget.value
      this.previewTarget.innerHTML = DOMPurify.sanitize(marked.parse(markdown))
    }
    // チェックボックスの状態に応じてテキストエリアとプレビューを切り替える
    this.editorTarget.classList.toggle("d-none", isPreviewMode)
    this.previewTarget.classList.toggle("d-none", !isPreviewMode)
  }
}
```

これでプレビュー機能の完成です！ :tada: 

![markdown.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7465/ed4d0457-0fec-43a4-84ed-1e3aba3ab43c.gif)

## さらに：詳細画面のMarkdownもHTMLに変換する

プレビュー機能はいったんこれで完成したのですが、このままだと詳細画面の表示が元のMarkdownのままになります。

![Screenshot 2025-05-18 at 14.19.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7465/9ba00462-26e8-4013-b226-b04936915732.png)

というわけで、この画面も先ほど作った`markdown_controller.js`を再利用して、MarkdownをHTMLに変換してみましょう。

### 1. Stimulusと連携させるためにHTMLに属性を追加する

Stimulusと連携させるために、詳細画面のHTML（ERB）に以下のような属性を追加します。

```diff_erb 
-<%= simple_format(blog.content) %>
+<div data-controller="markdown"
+  data-markdown-json-value="<%= blog.content.to_json %>">
+  <div data-markdown-target="preview"></div>
+</div>
```

各属性の意味や役割はHTMLコメントを参考にしてください。

```erb 
<!-- div内を markdown_controller の制御下におく -->
<!-- ブログの本文を jsonValue という名前で取得できるようにする -->
<!-- また、そのままだと改行文字がそのままHTML内で制御文字として作用したりするので、to_json でJSON化する -->
<div data-controller="markdown"
  data-markdown-json-value="<%= blog.content.to_json %>">
  <!-- Markdownから生成したHTMLをこのdiv内に表示する -->
  <!-- previewTargetという名前で参照するのはプレビュー機能作成時と同じ -->
  <div data-markdown-target="preview"></div>
</div>
```

なお、`data-markdown-json-value`の値は実際には以下のようなHTML（JSON化された文字列）で出力されます。

```html 
<div data-controller="markdown"
  data-markdown-json-value="&quot;最近、**Ruby on Rails**を使って簡単なブログアプリを作りました。\n\n#### できること\n- 記事の投稿\n- 記事の編集\n- 記事の削除\n\nまだ機能は少ないけど、自分で記事を投稿できるようになって感動！  \n\n今後は以下の機能も追加したいな。\n\n- 画像投稿機能\n- コメント機能\n- カテゴリー分け\n\nもし作ってみたい人は、[Railsガイド](https://railsguides.jp/)も参考になるのでチェックしてみてください。\n&quot;">
```

### 2. MarkdownからHTMLに変換して表示する

続いて、`markdown_controller.js`のコードを以下のように修正します。

```diff_javascript
 export default class extends Controller {
   static targets = ["editor", "preview", "previewSwitch"]
+  static values = {
+    json: String
+  }
 
   connect() {
+    if (this.hasJsonValue) {
+      const markdown = JSON.parse(this.jsonValue)
+      this.#renderPreview(markdown)
+    }
   }
 
   togglePreview() {
     const isPreviewMode = this.previewSwitchTarget.checked
     if (isPreviewMode) {
       const markdown = this.editorTarget.value
-      this.previewTarget.innerHTML = DOMPurify.sanitize(marked.parse(markdown))
+      this.#renderPreview(markdown)
     }
     this.editorTarget.classList.toggle("d-none", isPreviewMode)
     this.previewTarget.classList.toggle("d-none", !isPreviewMode)
   }
+
+  #renderPreview(markdown) {
+    this.previewTarget.innerHTML = DOMPurify.sanitize(marked.parse(markdown))
+  }
 }
```

各コードの意味は以下のコメントを参考にしてください。

```javascript
export default class extends Controller {
  static targets = ["editor", "preview", "previewSwitch"]
  // HTMLのdata属性に付与しているJSON化されたブログ本文をjsonValueという名前で受け取る
  static values = {
    json: String
  }

  // 画面表示時（HTML要素がこのコントローラに接続された時）に呼ばれる
  connect() {
    // 画面上にJSON化されたブログ本文がある場合に以下を実行する
    if (this.hasJsonValue) {
      // JSON化されたブログ本文を取得し、JSON.parseで元の文字列に戻す
      const markdown = JSON.parse(this.jsonValue)
      // HTMLの表示は共通化したメソッドに委譲する
      this.#renderPreview(markdown)
    }
  }

  togglePreview() {
    const isPreviewMode = this.previewSwitchTarget.checked
    if (isPreviewMode) {
      const markdown = this.editorTarget.value
      // HTMLの表示は共通化したメソッドに委譲する
      this.#renderPreview(markdown)
    }
    this.editorTarget.classList.toggle("d-none", isPreviewMode)
    this.previewTarget.classList.toggle("d-none", !isPreviewMode)
  }

  // 指定されたマークダウンをHTMLに変換してpreviewTargetに表示する共通メソッド
  #renderPreview(markdown) {
    this.previewTarget.innerHTML = DOMPurify.sanitize(marked.parse(markdown))
  }
}
```

これで詳細画面でもMarkdownからHTMLに変換されたブログ本文を表示できるようになりました！ :tada: 

![May-18-2025 14-50-46.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7465/db133bd5-e6a0-4daa-8d51-9ace08214e12.gif)

もちろん、詳細画面だけでなく一覧画面でもHTMLに変換されたブログ本文を表示できます。

![Screenshot 2025-05-18 at 14.54.11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7465/78d04a81-239a-4e38-bfa0-68b3118e2187.png)

Railsのscaffoldでは、一覧画面でも詳細画面でも `app/views/blogs/_blog.html.erb` というパーシャルviewを使っているので、Stimulusの変換処理もどちらの画面でも同じように動作します。

## 全体のdiffと実際のコード

文章で説明すると長く感じたかもしれませんが、[実際に書いたコードは50行未満](https://github.com/JunichiIto/markdown-preview-sandbox/pull/1)です。
この修正で発生した全体のdiffはこんな感じになりました。

![Screenshot 2025-05-18 at 15.11.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/7465/5c3dee80-dc18-436c-908c-f62a00914b59.png)

このサンプルアプリケーションはGitHubで公開しています。
コードをじっくり確認したい人や手元で動かして見たい人はぜひ参考にしてみてください。

https://github.com/JunichiIto/markdown-preview-sandbox

## まとめ
というわけで、この記事ではStimulusを使ってRailsアプリにMarkdownのプレビュー機能を実装する方法を説明してみました。

同じような機能を作ってみようと思っている方のお役に立つと幸いです😊

## PR: Hotwire.love に遊びに来てね！

冒頭でも書いたとおり、この機能は先日開催されたHotwire.loveでみんなでワイガヤしながら実装したコードです。

Hotwire.loveミートアップは毎月第3 or 第4水曜日にオンラインで開催しています。
Hotwire(Stimulus/Turbo/Native)に興味がある方なら誰でも参加OKです！
聞き専枠もあるので、初心者の人も熟練者の方もぜひ気軽に遊びに来てください😄

https://hotwire-love.connpass.com/