0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Rails 8 + Redcarpet + Stimulus で「表示と絶対にズレない」Markdownプレビューを作る

0
Posted at

title: Rails 8 + Redcarpet + Stimulus で「表示と絶対にズレない」Markdownプレビューを作る
tags:

  • Ruby
  • Rails
  • Markdown
  • Redcarpet
  • Stimulus
    private: false
    updated_at: ''
    id: null
    organization_url_name: null
    slide: false
    ignorePublish: false

はじめに

Rails で作っている記事投稿アプリに、

  1. 記事本文を Markdown で表示する機能
  2. 記事フォームでリアルタイムにプレビューする機能

を実装したので、備忘録としてまとめます。

プレビューは JS の Markdown ライブラリ(marked など)を使う方法もありますが、今回はサーバーで変換しています。理由は「プレビューで見た結果と、投稿後の詳細画面の表示が絶対にズレないようにしたかった」から。変換ロジックが 2 箇所にあると、片方だけオプションを足したときに静かにズレていきます。

記事の後半に、参考記事をそのまま写すと動かなかった箇所(tables: true が効かない、target="_blank" が消える)も書いています。

環境

項目 バージョン
Ruby 4.0.4
Rails 8.1.3
redcarpet 3.6.1
rails-html-sanitizer 1.7.1
フロント importmap-rails + Stimulus 1.3.4 + Bootstrap 5.3
テンプレート Haml(simple_form)

全体像

[記事詳細画面]  show.html.haml ─┐
                                ├─→ MarkdownHelper#markdown ─→ Redcarpet ─→ sanitize ─→ HTML
[記事フォーム]  Stimulus ──fetch──→ ArticlesController#preview ─┘

変換の入り口は MarkdownHelper#markdown ひとつだけ。詳細画面はビューから直接呼び、プレビューはコントローラの helpers.markdown 経由で同じものを呼びます。これで表示とプレビューが構造的にズレません。


パート1:Markdown の表示機能

1. Redcarpet を入れる

Gemfile
gem "redcarpet"
bundle install

2. MarkdownHelper を作る

app/helpers/markdown_helper.rb を新規作成します。

app/helpers/markdown_helper.rb
module MarkdownHelper
  # 拡張記法の設定。Redcarpet::Markdown.new に渡す
  EXTENSIONS = {
    fenced_code_blocks: true, # バッククォート3つで囲むコードブロックを有効にする
    no_intra_emphasis: true,  # 単語の途中のアンダースコアを強調記号として扱わない
    autolink: true            # ベタ書きしただけのURLを自動でリンクにしてくれる
  }.freeze

  # 出力方法の設定。Redcarpet::Render::HTML.new に渡す
  RENDER_OPTIONS = {
    escape_html: true, # 本文中に書かれた生のHTMLをタグとして出力せず、文字列としてエスケープする
    hard_wrap: true    # 改行をそのまま<br>に変換する
  }.freeze

  def markdown(text)
    renderer = Redcarpet::Render::HTML.new(RENDER_OPTIONS)         # 出力方法を決める
    html = Redcarpet::Markdown.new(renderer, EXTENSIONS).render(text.to_s) # Markdown → HTML
    sanitize(html)                                                  # 危険なタグを取り除く
  end
end

ポイントは 4 つです。

オプションを定数に切り出す

Redcarpet::Markdown.new を呼び出しのたびに組み立てても動きますが、「どのオプションを有効にしたか」がこのアプリの仕様そのものなので、定数にしてコメント付きで並べました。後から「テーブル使えないの?」と聞かれたときにここを見れば済みます。

no_intra_emphasis は実質必須

これが無いと some_snake_case_name の _..._ が強調記法として解釈され、some<em>snake</em>case_name になります。技術記事を書くアプリなら確実に踏みます。

hard_wrap で改行を <br> にする

Markdown の仕様では単一の改行は無視されますが、ブログ的な入力欄では「改行したら改行してほしい」が期待値なので有効にしました。

escape_html と sanitize の二重がけ

escape_html: true の時点で <script> は文字列にエスケープされるので、sanitize は理屈の上では冗長です。それでも両方かけているのは、片方だけの状態だと将来「これ要らなくない?」と消されたときに穴が空くから。「どちらか一方が消えても安全」という状態を作るための冗長さで、ここは意図的です。

3. ビューで呼ぶ

app/views/articles/show.html.haml
.markdown= markdown(@article.content)

sanitize を通した戻り値は html_safe な文字列なので、raw や html_safe を自分で付ける必要はありません(付けてしまうとサニタイズの意味が消えるので付けないこと)。

4. 生成された HTML にスタイルを当てる

ここが地味に面倒なところです。Redcarpet が吐くのは素の <h1> や <pre> で、生成されたタグに Bootstrap のユーティリティクラスを付けることができません。なので、ラッパーの .markdown の中だけタグ名で直接スタイルを当てます。

app/assets/stylesheets/application.bootstrap.scss
// Markdownから生成されたHTMLのスタイル。
// 生成されたタグにクラスを付けられないので、ここだけタグ名で直接当てる。
.markdown {
  font-size: 1rem;
  line-height: 1.9;
  overflow-wrap: break-word;

  p {
    margin-bottom: 1.25rem;

    &:last-child {
      margin-bottom: 0;
    }
  }

  // 本文中の見出しは記事タイトル(.fs-2 = 2rem)より小さくする。
  // 素の h1 は 2.5rem あり、そのままだとタイトルを追い越してしまう。
  h1, h2, h3, h4, h5, h6 {
    font-weight: 700;
    line-height: 1.4;
    margin-top: 2rem;
    margin-bottom: 1rem;
  }

  h1 { font-size: 1.5rem; }
  h2 { font-size: 1.35rem; }
  h3 { font-size: 1.2rem; }
  h4, h5, h6 { font-size: 1.05rem; }

  // 本文が見出しで始まるとき、上に余計な余白を作らない
  > :first-child {
    margin-top: 0;
  }

  ul, ol {
    margin-bottom: 1.25rem;
    padding-left: 1.5rem;
  }

  blockquote {
    margin: 0 0 1.25rem;
    padding: 0.25rem 0 0.25rem 1rem;
    border-left: 4px solid var(--bs-border-color);
    color: var(--bs-secondary-color);
  }

  pre {
    margin-bottom: 1.25rem;
    padding: 1rem;
    border-radius: 0.5rem;
    background-color: var(--bs-secondary-bg);
    overflow-x: auto; // 長いコードは横スクロールさせる
  }

  // 背景はインラインの code にだけ付ける。
  // pre の中の code にも付くと背景が二重になるので下で打ち消す。
  code {
    padding: 0.15em 0.35em;
    border-radius: 0.25rem;
    background-color: var(--bs-secondary-bg);
    font-size: 0.9em;
  }

  pre code {
    padding: 0;
    background-color: transparent;
    font-size: inherit;
  }

  img {
    max-width: 100%;
    height: auto;
  }
}

つまずいたところ

  • 見出しがタイトルより大きくなる:素の <h1> は 2.5rem あり、記事タイトル(.fs-2 = 2rem)を追い越してしまいます。本文の見出しは全部小さめに上書きしました。
  • コードブロックの背景が二重になる:code に背景色を付けると <pre><code> の入れ子で背景が重なります。pre code で打ち消すのを忘れずに。
  • 色は決め打ちせず var(--bs-secondary-bg) などの Bootstrap CSS 変数を使うと、テーマ変更にそのまま追従します。

5. 表示機能のテスト

変換オプションの指定漏れは、ヘルパーのユニットテストでしか捕まりません。特に hard_wrap。

spec/helpers/markdown_helper_spec.rb
require "rails_helper"

RSpec.describe MarkdownHelper do
  describe "#markdown" do
    it "見出しをHTMLに変換すること" do
      expect(helper.markdown("# 見出し")).to include("<h1>見出し</h1>")
    end

    it "リストをHTMLに変換すること" do
      expect(helper.markdown("- A\n- B")).to include("<li>A</li>")
    end

    it "コードブロックをHTMLに変換すること" do
      expect(helper.markdown("```ruby\nputs :hi\n```")).to include("<pre><code")
    end

    # Capybara の have_text は改行を無視するのでシステムスペックでは検知できない。
    # hard_wrap の指定漏れはここでしか捕まらない。
    it "改行を br に変換すること" do
      expect(helper.markdown("1行目\n2行目")).to include("<br>")
    end

    it "URLを自動でリンクにすること" do
      expect(helper.markdown("https://example.com")).to include('<a href="https://example.com">')
    end

    it "単語中のアンダースコアを強調として扱わないこと" do
      expect(helper.markdown("some_snake_case_name")).to include("some_snake_case_name")
    end

    it "scriptタグをタグとして出力しないこと" do
      result = helper.markdown("<script>alert(1)</script>")

      expect(result).not_to include("<script>")
      expect(result).to include("&lt;script&gt;")
    end

    it "imgタグをタグとして出力しないこと" do
      expect(helper.markdown("<img src=x onerror=alert(1)>")).not_to include("<img")
    end
  end
end

helper.markdown のように helper 経由で呼ぶと、ヘルパースペックの中で sanitize などの Rails のビューヘルパーも一緒に使える状態になります。


【重要】参考記事どおりに書くと動かなかった 2 つのオプション

Redcarpet の設定例を検索すると tables: true と link_attributes: { target: "_blank" } がほぼセットで出てきます。Rails の sanitize と併用すると、このどちらも効きません。

tables: true を書いてもテーブルは出ない

Redcarpet は正しく <table> を生成しますが、Rails の sanitize のデフォルト許可リストに table / tr / td が入っていないため、直後に丸ごと削除されます。実測すると <table>...</table> が "A1"(セルの中身だけ)になりました。

link_attributes で付けた target="_blank" も消える

同じ理由で、sanitize が target と rel 属性を落とします。

どうしたか

「設定はあるのに効かない」という状態がいちばんタチが悪いので、両方とも書かないことにしました。結果として sanitize(html) を引数なしで呼べる= Rails デフォルトの許可リストにそのまま乗るので、コードが短くなり、セキュリティ更新も自動で付いてきます。

どうしてもテーブルや別タブ表示が必要になったら、sanitize に許可リストを渡す形で明示的に開けます(それぞれ 2 行程度の変更で後戻りできます)。

# 例:テーブルを許可する場合
sanitize(html, tags: Rails::HTML5::SafeListSanitizer.allowed_tags + %w[table thead tbody tr th td])

同様に、シンタックスハイライトの導入手順として有名な rouge.scss.erb 方式も、propshaft + cssbundling 構成では動きません。入れるなら rougify style で CSS を生成して scss から @import する形になります。


パート2:Markdown プレビュー機能

どちらで変換するか:クライアント側 JS vs サーバー側ヘルパーの再利用

実装に入る前に、いちばん悩んだところです。選択肢は 2 つありました。

  • A. クライアント側で変換する — marked / markdown-it などを読み込み、JS だけで完結させる
  • B. サーバー側の既存ヘルパーを再利用する — 本文を POST して MarkdownHelper#markdown の結果を受け取る

比較表

観点 A. クライアント側 JS B. サーバー側ヘルパー再利用
詳細画面の表示との一致 ❌ 別実装なので保証できない ⭕ 同じコードなので構造的に一致する
反応速度 ⭕ 通信なしで即時 △ デバウンス+往復のぶん遅れる
オフライン / 不安定な回線 ⭕ 影響を受けない ❌ プレビューが出せない(エラー表示が要る)
JS の複雑さ ⭕ 変換して差し込むだけ ❌ デバウンス・中断・エラー処理が要る
サーバー負荷 ⭕ ゼロ △ 入力のたびにリクエストが飛ぶ
サニタイズ ❌ DOMPurify などをもう一式、許可リストも二重管理 ⭕ Rails の sanitize 一本で済む
依存ライブラリ ❌ 増える(importmap なら pin も必要) ⭕ 増えない
攻撃面 ⭕ エンドポイントを増やさない △ 認証付きエンドポイントが 1 つ増える
テスト ❌ 変換の担保を JS 側にも用意することになる ⭕ ヘルパースペック 1 箇所で済み、あとは配線テストだけ

A(クライアント側)の落とし穴

「どちらも Markdown なんだから同じ結果になるでしょ」とはなりません。Markdown には方言があります。

  • 改行の扱い(hard_wrap 相当)
  • 単語中のアンダースコア(no_intra_emphasis 相当)
  • URL の自動リンク(autolink 相当)
  • テーブルや脚注などの拡張記法をどこまで持つか

Redcarpet と marked のオプションを 1 つずつ突き合わせれば「だいたい同じ」までは寄せられます。ただし 完全一致は保証できませんし、片方にオプションを足したときに自動では追従しません。

さらに厄介なのがサニタイズです。今回サーバー側は Rails の sanitize に任せていて、その結果として(前述のとおり)テーブルが落ちます。クライアント側を別実装にすると 「プレビューでは表として見えていたのに、投稿したら消えた」 が普通に起こります。許可リストを 2 箇所で揃え続けるのは現実的ではありません。

B(サーバー側)の落とし穴

一方で B にも代償があります。JS が思ったより複雑になります。

入力のたびに通信が飛ぶので、

  • デバウンスで間引く
  • 追い越したレスポンスを AbortController で潰す
  • 失敗したときの表示を用意する
  • 画面離脱時にタイマーと通信を片付ける

が必要になります。これが後述の Stimulus コントローラが 80 行ある理由のほぼ全部で、「変換を投げる」部分自体は 10 行もありません。

結論:今回は B を選んだ

決め手は プレビューは詳細画面の見た目を予告するものであって、ズレたら存在意義が無い という点です。このアプリは技術記事の共有が目的なので、コードブロックや見出しの見え方が投稿前後で違うのは致命的でした。

そして B のデメリットである「JS が複雑になる」は、一度書けばそれ以上増えない複雑さです。対して A のデメリットである「二重管理」は、オプションを足すたび、ライブラリを更新するたびに効いてくる種類のコストです。1 回払うか払い続けるかの違いだと考えて B にしました。

A を選んだほうがいい場面

もちろん常に B が正解ではありません。次のようなケースでは A が素直です。

  • オフラインでも編集できる必要がある(PWA など)
  • 同時に書く人が多く、入力のたびのリクエストがサーバーに響く
  • そもそも詳細画面が無い(変換結果を出す先がプレビューだけ)ので、一致させる相手がいない
  • 本文がとても長く、往復のたびに全文を送るのが無駄になる

A を選ぶ場合も、「サーバー側の変換結果が正」と決めたうえで、同じ入力を両方に通して差分を見るテストを 1 本持っておくと、ズレたときに気づけます。

なお B の中でも、JSON ではなく Turbo Stream で HTML 片を返す書き方もできます。今回は「プレビュー領域の中身を差し替えるだけ」で Turbo Frame を足すほどでもなかったので、素の fetch + JSON にしました。

方針:変換はサーバーに投げる

冒頭に書いたとおり、変換は ArticlesController#preview に任せます。

  • メリット:プレビューの結果が詳細画面の表示と必ず一致する。JS 側の Markdown ライブラリを増やさなくていい。サニタイズも 1 箇所で済む。
  • デメリット:1 文字打つたびに通信が飛びかねない → デバウンスで対処します。

1. ルーティングとエンドポイント

config/routes.rb
resources :articles, only: %i[index new edit create update destroy show] do
  resources :comments, only: %i[create destroy]
  post :preview, on: :collection
end

新規作成画面でも使うので、on: :member ではなく on: :collection(/articles/preview)です。

app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  before_action :authenticate_user!, only: %i[new edit create update destroy preview]

  # ...

  def preview
    render json: { content: helpers.markdown(params[:content]) }
  end
end

5 行で終わります。 コントローラで独自に変換せず helpers.markdown を呼ぶのが今回いちばん大事なところです。

preview を authenticate_user! の対象に入れているのもポイントで、これを忘れると「誰でも叩ける Markdown 変換 API」を公開したことになります。

2. フォーム側(Haml + Bootstrap のタブ)

「編集」「プレビュー」を Bootstrap のタブで切り替えます。タブの切り替え自体は Bootstrap に任せ、Stimulus は変換だけを担当します。

app/views/articles/_form.html.haml
= simple_form_for article do |f|
  = f.input :title, label: "タイトル", placeholder: "記事のタイトルを入力"

  %ul.nav.nav-tabs{ role: "tablist" }
    %li.nav-item{ role: "presentation" }
      %button#editor-tab.nav-link.active{ type: "button", role: "tab", data: { bs_toggle: "tab", bs_target: "#editor-pane" }, aria: { controls: "editor-pane", selected: "true" } }
        編集
    %li.nav-item{ role: "presentation" }
      %button#preview-tab.nav-link{ type: "button", role: "tab", data: { bs_toggle: "tab", bs_target: "#preview-pane" }, aria: { controls: "preview-pane", selected: "false" } }
        プレビュー

  .tab-content.border.border-top-0.rounded-bottom.p-3{ data: { controller: "markdown-preview", markdown_preview_url_value: preview_articles_path } }
    #editor-pane.tab-pane.fade.show.active{ role: "tabpanel", aria: { labelledby: "editor-tab" } }
      = f.input :content, label: "本文", label_html: { class: "visually-hidden" }, as: :text, input_html: { rows: 14, data: { markdown_preview_target: "source", action: "input->markdown-preview#update" } }, placeholder: "記事の本文を入力", wrapper_html: { class: "mb-0" }
    #preview-pane.tab-pane.fade{ role: "tabpanel", aria: { labelledby: "preview-tab" } }
      .markdown.markdown-preview{ data: { markdown_preview_target: "preview" } }
        %p.text-muted ここにプレビューが表示されます
    -# プレビューに失敗したときの表示。文言をJSに持たせないためテンプレートとして置いている
    %template{ data: { markdown_preview_target: "error" } }
      %p.text-danger
        %i.bi.bi-exclamation-triangle.me-1
        プレビューを表示できませんでした。通信の状態を確認してください。

  .d-grid.mt-4
    = f.button :submit, "投稿する", class: "btn btn-primary btn-lg"

工夫した点:

  • プレビュー領域に .markdown クラスを付ける。詳細画面と同じ CSS がそのまま当たるので、見た目も一致します。
  • エラー時の文言は <template> に置く。JS に日本語のメッセージを書くと、i18n したくなったときに困るので、文言はビュー側に集約しています。
  • URL は values で渡す(markdown_preview_url_value)。JS 側にパスをハードコードしません。
  • data-action: "input->markdown-preview#update" で入力のたびに update が呼ばれます。

3. Stimulus コントローラ

本体です。app/javascript/controllers/markdown_preview_controller.js を新規作成します。

app/javascript/controllers/markdown_preview_controller.js
import { Controller } from "@hotwired/stimulus"

// 入力が止まってから変換を投げるまでの待ち時間。
const DEBOUNCE_MS = 400

// 本文をサーバーに送ってMarkdownをHTMLに変換し、プレビュー領域に表示する。
// 変換とサニタイズは ArticlesController#preview(中身は MarkdownHelper)に任せているので、
// プレビューの結果は記事詳細画面の表示と必ず一致する。
export default class extends Controller {
  static targets = ["source", "preview", "error"]
  static values = { url: String }

  #timer = null
  #inFlight = null
  #placeholder = null

  connect() {
    // 本文が空になったときに戻す先として、ビューに書いてある初期表示を覚えておく
    this.#placeholder = this.previewTarget.innerHTML

    // 編集画面は本文が入った状態で開くので、最初に一度だけ変換しておく
    if (this.sourceTarget.value) this.#render()
  }

  // 画面を離れるときに、待機中のタイマーと飛びかけのリクエストを片付ける
  disconnect() {
    clearTimeout(this.#timer)
    this.#inFlight?.abort()
  }

  update() {
    clearTimeout(this.#timer)
    this.#timer = setTimeout(() => this.#render(), DEBOUNCE_MS)
  }

  async #render() {
    // 本文が空ならサーバーに聞くまでもないので、最初の案内文に戻す
    if (!this.sourceTarget.value.trim()) {
      this.previewTarget.innerHTML = this.#placeholder
      return
    }

    // 前のリクエストがまだ返ってきていなければ中断する。
    // 中断しないと、古いレスポンスが後から届いて新しいプレビューを上書きすることがある。
    this.#inFlight?.abort()
    this.#inFlight = new AbortController()

    try {
      const response = await fetch(this.urlValue, {
        method: "POST",
        headers: this.#headers(),
        body: JSON.stringify({ content: this.sourceTarget.value }),
        signal: this.#inFlight.signal
      })
      if (!response.ok) throw new Error(`プレビューの取得に失敗しました (${response.status})`)

      const data = await response.json()

      this.previewTarget.innerHTML = data.content
    } catch (error) {
      // 中断したときのエラーは想定どおりなので何もしない
      if (error.name === "AbortError") return

      // プレビューが出せなくても編集は続けられるので、案内を出すだけにとどめる
      this.previewTarget.innerHTML = this.errorTarget.innerHTML
      console.error(error)
    }
  }

  // Railsのフォーム経由ではないPOSTなので、CSRFトークンは自分で付ける。
  // ただしテスト環境は allow_forgery_protection = false で csrf_meta_tags が
  // 何も出力しないため、メタタグが無い場合も動くようにしておく。
  #headers() {
    const headers = { "Content-Type": "application/json" }
    const token = document.querySelector('meta[name="csrf-token"]')?.content
    if (token) headers["X-CSRF-Token"] = token

    return headers
  }
}

importmap 構成なら pin_all_from "app/javascript/controllers" が既にあるので、ファイルを置くだけで markdown-preview として認識されます。

解説:ここを外すと地味にバグる 5 点

① デバウンス(400ms)

1 文字ごとにリクエストを投げると無駄すぎるので、入力が止まってから 400ms 待って送ります。体感で遅さを感じず、かつ連打にならないバランスがこのあたりでした。

② AbortController で古いリクエストを潰す

これが無いと「稀に古い内容が表示される」バグが出ます。 デバウンスしていても、通信が遅いときにリクエストが 2 本重なることはあり、先に投げたほうが後に返ってくると新しいプレビューを上書きしてしまいます。新しく投げる前に前のを abort() するのが確実です。

abort() すると fetch は AbortError を投げるので、catch の中で「想定どおりの中断」として無視します。ここを分けないと、入力するたびにエラー表示が出ます。

③ disconnect() で後片付けする

Turbo は画面遷移しても JS のコンテキストが生き続けるので、タイマーと通信を明示的に止めます。止めないと、離脱後に返ってきたレスポンスが既に消えた DOM を触りにいきます。

④ 初期表示を覚えておく

「ここにプレビューが表示されます」という案内文を JS の文字列で持つのではなく、connect() の時点で DOM から読み取って覚えておき、本文が空になったら書き戻します。文言がビュー側に一本化されます。

⑤ 編集画面の初回変換

新規作成画面は本文が空ですが、編集画面は本文が入った状態で開きます。connect() で 1 回だけ変換しておかないと、「編集画面を開いてプレビュータブを押したのに空っぽ」になります。

CSRF トークンについて

fetch は Rails のフォーム経由ではないので、X-CSRF-Token を自分で付ける必要があります。csrf_meta_tags がレイアウトにある前提です。

ただしテスト環境は config.action_controller.allow_forgery_protection = false で csrf_meta_tags が何も出力しないため、メタタグが存在しない場合も動くように ?.content とオプショナルなヘッダ追加にしています。ここを document.querySelector(...).content と書くとシステムスペックだけ落ちます。

4. タブ切り替えで画面が跳ねないようにする

地味ですが効きます。プレビューの中身が短いとタブを切り替えた瞬間に箱の高さが変わり、画面がガタつきます。本文欄とだいたい同じ高さを確保しておきます。

app/assets/stylesheets/application.bootstrap.scss
// 記事フォームのプレビュー領域。本文欄(rows="14" = 350px)とほぼ同じ高さを
// 確保しておく。中身が短いままタブを切り替えると箱の高さが変わって画面が跳ねるため。
.markdown-preview {
  min-height: 22rem;
}

5. プレビュー機能のテスト

リクエストスペック:配線と認証だけ見る

spec/requests/articles_spec.rb
require "rails_helper"

RSpec.describe "Articles", type: :request do
  let(:user) { User.create!(email: "user1@example.com", password: "password") }

  describe "POST /articles/preview" do
    # Markdownの変換内容そのものは markdown_helper_spec.rb が担保しているので、
    # ここではエンドポイントがヘルパーを通してJSONを返す配線だけを確認する。
    context "ログインしているとき" do
      before { sign_in user }

      it "MarkdownをHTMLに変換したJSONを返すこと" do
        post preview_articles_path, params: { content: "# 見出し" }, as: :json

        expect(response).to have_http_status(:ok)
        expect(response.parsed_body["content"]).to include("<h1>見出し</h1>")
      end
    end

    # 実際のfetchと同じJSONで送るため、Deviseはリダイレクトではなく401を返す
    # (navigational_formats に :json が含まれないため)。
    context "ログインしていないとき" do
      it "401を返すこと" do
        post preview_articles_path, params: { content: "# 見出し" }, as: :json

        expect(response).to have_http_status(:unauthorized)
      end
    end
  end
end

Devise の未ログイン時の挙動は、リクエストの形式で変わります。HTML リクエストならログイン画面へ 302、as: :json なら 401。navigational_formats に :json が含まれていないためです。実際の fetch と同じ形で送るなら 401 を期待するのが正解です。

システムスペック:ユーザーの操作で確かめる

spec/system/articles_spec.rb
describe "記事フォームのプレビュー" do
  before { login_as(user, scope: :user) }

  it "入力した本文がMarkdownとして表示されること" do
    visit new_article_path
    fill_in "本文", with: "# 見出し"
    click_button "プレビュー"

    expect(page).to have_css("#preview-pane h1", text: "見出し")
  end

  it "編集画面では開いた直後から既存の本文が表示されていること" do
    article = user.articles.create!(title: "記事のタイトル", content: "# 既存の見出し")

    visit edit_article_path(article)
    click_button "プレビュー"

    expect(page).to have_css("#preview-pane h1", text: "既存の見出し")
  end

  it "本文を空にすると案内文に戻ること" do
    visit new_article_path
    fill_in "本文", with: "# 見出し"
    click_button "プレビュー"
    expect(page).to have_css("#preview-pane h1", text: "見出し")

    click_button "編集"
    fill_in "本文", with: ""
    click_button "プレビュー"

    expect(page).to have_text("ここにプレビューが表示されます")
    expect(page).to have_no_css("#preview-pane h1")
  end

  it "変換に失敗したときは案内を表示すること" do
    visit new_article_path
    # 送信先を存在しないURLに差し替えて、変換に失敗する状況を作る
    page.execute_script(<<~JS)
      document.querySelector("[data-controller='markdown-preview']")
        .setAttribute("data-markdown-preview-url-value", "/articles/not-found-on-purpose")
    JS

    fill_in "本文", with: "# 見出し"
    click_button "プレビュー"

    expect(page).to have_text("プレビューを表示できませんでした")
  end
end

テストで工夫した点:

  • have_text ではなく have_css("#preview-pane h1", ...) で確認する。have_text("見出し") だと変換されていない生の # 見出し でも通ってしまい、テストの意味がなくなります。「h1 タグになっていること」を見るのが肝心です。
  • エラー表示のテストは URL を差し替えて作る。Stimulus の values は data-* 属性なので、execute_script で属性を書き換えるだけで「通信に失敗する状況」を再現できます。サーバーを止めたりモックを仕込んだりする必要がありません。
  • デバウンスを待つ処理は書かなくていい。Capybara の have_css / have_text は自動で待ってくれるので、sleep は不要です。

まとめ:ハマったところ一覧

つまずき 原因 対処
tables: true を書いてもテーブルが出ない Rails の sanitize が table 系タグを許可していない オプション自体を書かない(必要なら sanitize に許可リストを渡す)
target="_blank" が付かない sanitize が target / rel を落とす 同上
some_snake_case が斜体になる Redcarpet のデフォルト挙動 no_intra_emphasis: true
改行が反映されない Markdown の仕様 hard_wrap: true(テストはヘルパースペックで)
本文の見出しがタイトルより大きい 素の h1 は 2.5rem .markdown h1 以下を上書き
コードブロックの背景が二重 pre と code の両方に背景 pre code で打ち消す
稀に古いプレビューが表示される レスポンスの追い越し AbortController で前のリクエストを中断
システムスペックだけ CSRF で落ちる テスト環境では csrf_meta_tags が空 メタタグが無くても動くように書く
未ログインのリクエストスペックが 302 を期待して落ちる as: :json だと Devise は 401 401 を期待する
タブ切り替えで画面が跳ねる プレビュー領域の高さが可変 min-height を確保

一番のポイントは、繰り返しになりますが 変換ロジックを MarkdownHelper 1 箇所に集約して、プレビューはそれをサーバー経由で呼ぶ ことです。JS 側に Markdown ライブラリを置かないので、オプションを増やしてもサニタイズ方針を変えても、表示とプレビューが自動的に揃います。

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?