はじめに
先日開催されたHotwire.love Vol.42で、以下のようなMarkdownのプレビュー機能を実装してみました。
(Previewにチェックを入れると、テキストエリアに書いたMarkdownがHTMLとしてプレビュー表示される)
Stimulusを使うとわずか50行未満で実装できたので、この記事ではその方法を紹介します。
動作確認したバージョン
この記事で使った各ライブラリのバージョンは以下の通りです。
- rails 8.0.2
- stimulus 3.2.2 (stimulus-rails 1.3.4)
- marked 15.0.11
- dompurify 3.2.5
また、コードは以下のGitHubリポジトリで公開しています。
サンプルアプリの仕様(変更前)
ここではごく簡単なブログアプリをサンプルアプリとして使用します。
よくあるScaffoldを使ったRailsアプリケーションです。
編集画面
詳細画面
今からこのアプリケーションにMarkdownのプレビュー機能を実装していきます。
実装手順
それではプレビュー機能の実装手順を説明します。
1. Markdown用のnpmパッケージをインストール
今回、MarkdownからHTMLへの変換処理はクライアントサイド(ブラウザ内)で実行します。
そのためにmarkedを使用します。
また、生成されたHTMLをサニタイズするために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
に以下のファイルが生成されます。
import { Controller } from "@hotwired/stimulus"
// Connects to data-controller="markdown"
export default class extends Controller {
connect() {
}
}
3. プレビューを切り替えるためのチェックボックスを追加
app/views/blogs/_form.html.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>
以下のようにチェックボックスが表示されます。
4. Stimulusと連携させるためにHTMLに属性を追加する
続いて、Stimulusと連携させるためにHTML(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コメントを参考にしてください。
<!-- 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
に以下のようなコードを書きます。
コードの意味はコメントを参考にしてください。
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))
}
}
こうすると、以下のようにテキストエリアに入力した内容に応じて、リアルタイムにプレビューが更新されます。
「あれっ、チェックボックスでプレビューを切り替えるんじゃないの?」と思った方へ。
安心してください。それは次のステップで実装します。
この時点ではいったんテキストエリアの入力とマークダウンのプレビューが連動していることを確認しました。
6. プレビュー表示を切り替えるためにHTML属性を追加する
チェックボックスを使ってプレビュー表示を切り替えるために、以下のようなHTML属性を付与します。
<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を使っています。
"d-none"は指定した要素を非表示にするためのBootstrapのクラス名です。
<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でプレビューを切り替えられるようにします。
変更点は以下の通りです。
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)
+ }
}
各コードの意味は以下のコメントを参考にしてください。
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)
}
}
これでプレビュー機能の完成です!
さらに:詳細画面のMarkdownもHTMLに変換する
プレビュー機能はいったんこれで完成したのですが、このままだと詳細画面の表示が元のMarkdownのままになります。
というわけで、この画面も先ほど作ったmarkdown_controller.js
を再利用して、MarkdownをHTMLに変換してみましょう。
1. Stimulusと連携させるためにHTMLに属性を追加する
Stimulusと連携させるために、詳細画面のHTML(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コメントを参考にしてください。
<!-- 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化された文字列)で出力されます。
<div data-controller="markdown"
data-markdown-json-value=""最近、**Ruby on Rails**を使って簡単なブログアプリを作りました。\n\n#### できること\n- 記事の投稿\n- 記事の編集\n- 記事の削除\n\nまだ機能は少ないけど、自分で記事を投稿できるようになって感動! \n\n今後は以下の機能も追加したいな。\n\n- 画像投稿機能\n- コメント機能\n- カテゴリー分け\n\nもし作ってみたい人は、[Railsガイド](https://railsguides.jp/)も参考になるのでチェックしてみてください。\n"">
2. MarkdownからHTMLに変換して表示する
続いて、markdown_controller.js
のコードを以下のように修正します。
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))
+ }
}
各コードの意味は以下のコメントを参考にしてください。
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に変換されたブログ本文を表示できるようになりました!
もちろん、詳細画面だけでなく一覧画面でもHTMLに変換されたブログ本文を表示できます。
Railsのscaffoldでは、一覧画面でも詳細画面でも app/views/blogs/_blog.html.erb
というパーシャルviewを使っているので、Stimulusの変換処理もどちらの画面でも同じように動作します。
全体のdiffと実際のコード
文章で説明すると長く感じたかもしれませんが、実際に書いたコードは50行未満です。
この修正で発生した全体のdiffはこんな感じになりました。
このサンプルアプリケーションはGitHubで公開しています。
コードをじっくり確認したい人や手元で動かして見たい人はぜひ参考にしてみてください。
まとめ
というわけで、この記事ではStimulusを使ってRailsアプリにMarkdownのプレビュー機能を実装する方法を説明してみました。
同じような機能を作ってみようと思っている方のお役に立つと幸いです😊
PR: Hotwire.love に遊びに来てね!
冒頭でも書いたとおり、この機能は先日開催されたHotwire.loveでみんなでワイガヤしながら実装したコードです。
Hotwire.loveミートアップは毎月第3 or 第4水曜日にオンラインで開催しています。
Hotwire(Stimulus/Turbo/Native)に興味がある方なら誰でも参加OKです!
聞き専枠もあるので、初心者の人も熟練者の方もぜひ気軽に遊びに来てください😄