redcarpetで新しいマークダウン記法をサクッと追加する

コロナウィルスにも負けずに相変わらずサウナに行っているプレイライフのエンジニアの合原です。

昨日ちょうど、弊社の特集記事の入稿画面に、[redcarpet](https://github.com/vmg/redcarpet)を用いて、マークダウンでの入力を拡張したので、それについてまとめます。

# なぜマークダウン記法の追加をした？
弊社では、従来なかなかの骨のある作りのオリジナルのhtmlエディタが実装されており、
テストもなく、何がデグレするかわからない状態です。
手を加えるとなると、手間も時間もかかることから難しいなと。
何よりも、

* 「使用者」である編集担当も、使いやすいと思っていない
* マークダウンも使えるようにして欲しいと言う声が前々からあった
 
といった点から、redcarpetを用いたマークダウン記法を部分的に追加をすることにしました。
本来であれば、エディタ機能自体リプレースしたいところですが、別案件もあったりと。。。現状厳しく（泣

また一方で、弊社の場合、すでにredcarpetの導入が済んでいたことも対応方法の決定打になりました。


# 弊社の環境
Rails 5.2.3

まず先に、下記の通り、簡単にredcarpetの導入についてまとめます。
詳細については、[こちら](https://github.com/vmg/redcarpet)もご参考に。

# redcarpetの導入
[gem](https://github.com/vmg/redcarpet)インストール


## viewから使えるようにヘルパーを追加

> app/helpers/markdown_helper.rb

```rb
def format_markdown(text)
  renderer = Redcarpet::Render::HTML.new
  markdown = Redcarpet::Markdown.new(renderer, options)
  markdown.render(text).html_safe
end
```

あとはこれを使えばマークダウンが使えるようになります。

# html tag をカスタマイズする方法
[ここ](https://github.com/vmg/redcarpet#darling-i-packed-you-a-couple-renderers-for-lunch)にある通り。

```rb
Redcarpet::Render::HTML.new(options = {})
```

の`options`を使うことでも可能。

> example

```rb
renderer = Redcarpet::Render::HTML.new(no_links: true, hard_wrap: true)
```

# オリジナルのhtmltag を設定したい場合
[ここ](https://github.com/vmg/redcarpet#and-you-can-even-cook-your-own)にある通り。
`Redcarpet::Render::HTML`を継承したサブクラスで、
各種`renderers`メソッドをオーバライドすることで可能。

```rb
class CustomRender < Redcarpet::Render::HTML
  def block_quote(quote)
    %(<blockquote class="my-custom-class">#{quote}</blockquote>)
  end
end
```


# 独自の記法追加する方法（実際にやったのはこれです
[ここ](https://github.com/vmg/redcarpet#prepost-process)にある通り、redcarpetにはコールバックメソッドが用意されています。
`postprocess`を使えば、filter_htmlオプションの影響を受けず変更が可能となるので、
こちらを下記のようにオーバライドします。

## Redcarpet::Render::HTMLのサブクラスを作る

> lib/custom_markdown.rb

```ruby
class CustomMarkdown < Redcarpet::Render::HTML
  :

  def header(title, level)
    "#{'#' * level} #{title}"
  end

  :
  

  def autolink(link, link_type)
  　:
　end

  def codespan(code)
    :
  end

  def postprocess(full_document)
    MarkdownCustoms::ClassNameAppender.new(full_document: full_document).customized_full_document
  end

  :
end
```

メソッド内では、独自のクラスを追加するマークダウンを解釈して、class属性を付与したHTMLが返します。
あとは、呼び出しているこのクラスのテスト書きます。

### テスト(仕様）
```rb

require 'rails_helper'

RSpec.describe MarkdownCustoms::ClassNameAppender, type: :model do
  describe '#custom_full_document' do
    let(:class_name_appender) { MarkdownCustoms::ClassNameAppender.new(full_document: full_document) }
    subject { class_name_appender.customized_full_document }
    context 'when full_document == nil' do
      let(:full_document) { nil }
      it { is_expected.to eq full_document }
    end
    context 'when full_document not include class_name ' do
      let(:full_document) { '<a href="https://t.afi-b.com/visit" target="_blank" rel="nofollow noopener" class="">アンカーテキスト</a>' } # rubocop:disable Metrics/LineLength
      it { is_expected.to eq full_document }
    end
    context 'when full_document include valid 1 class_name ' do
      let(:full_document) { '[class: button-filled--action]<a href="https://t.afi-b.com/visit" target="_blank" rel="nofollow noopener" class="">アンカーテキスト</a>' } # rubocop:disable Metrics/LineLength
      let(:expected_string) { '<a href="https://t.afi-b.com/visit" target="_blank" rel="nofollow noopener" class="button-filled--action">アンカーテキスト</a>' } # rubocop:disable Metrics/LineLength
      it { is_expected.to eq expected_string }
    end
    context 'when full_document include valid 2 class_name ' do
      let(:full_document) { '[class: .l-flex--center.button-filled--action]<a href="https://t.afi-b.com/visit" target="_blank" rel="nofollow noopener" class="">アンカーテキスト</a>' } # rubocop:disable Metrics/LineLength
      let(:expected_string) { '<a href="https://t.afi-b.com/visit" target="_blank" rel="nofollow noopener" class="l-flex--center button-filled--action">アンカーテキスト</a>' } # rubocop:disable Metrics/LineLength
      it { is_expected.to eq expected_string }
    end
    context 'when full_document include valid 1 class_name surrounded single quatation' do
      let(:full_document) { '[ class: &#39;button-filled--action&#39;]<a href="https://t.afi-b.com/visit" target="_blank" rel="nofollow noopener" class="">アンカーテキスト</a>' } # rubocop:disable Metrics/LineLength
      let(:expected_string) { '<a href="https://t.afi-b.com/visit" target="_blank" rel="nofollow noopener" class="button-filled--action">アンカーテキスト</a>' } # rubocop:disable Metrics/LineLength
      it { is_expected.to eq expected_string }
    end
    context 'when full_document include valid 2 class_name surrounded single quatation' do
      let(:full_document) { '[class: &#39;l-flex--center.button-filled--action&#39;]<a href="https://t.afi-b.com/visit" target="_blank" rel="nofollow noopener" class="">アンカーテキスト</a>' } # rubocop:disable Metrics/LineLength
      let(:expected_string) { '<a href="https://t.afi-b.com/visit" target="_blank" rel="nofollow noopener" class="l-flex--center button-filled--action">アンカーテキスト</a>' } # rubocop:disable Metrics/LineLength
      it { is_expected.to eq expected_string }
    end
    context 'when full_document include valid 1 class_name surrounded double quatation' do
      let(:full_document) { '[class: &#34;button-filled--action&#34;]<a href="https://t.afi-b.com/visit" target="_blank" rel="nofollow noopener" class="">アンカーテキスト</a>' } # rubocop:disable Metrics/LineLength
      let(:expected_string) { '<a href="https://t.afi-b.com/visit" target="_blank" rel="nofollow noopener" class="button-filled--action">アンカーテキスト</a>' } # rubocop:disable Metrics/LineLength
      it { is_expected.to eq expected_string }
    end
    context 'when full_document include valid 2 class_name surrounded double quatation' do
      let(:full_document) { '[class: &#34;.l-flex--center.button-filled--action&#34;]<a href="https://t.afi-b.com/visit" target="_blank" rel="nofollow noopener" class="">アンカーテキスト</a>' } # rubocop:disable Metrics/LineLength
      let(:expected_string) { '<a href="https://t.afi-b.com/visit" target="_blank" rel="nofollow noopener" class="l-flex--center button-filled--action">アンカーテキスト</a>' } # rubocop:disable Metrics/LineLength
      it { is_expected.to eq expected_string }
    end
    context 'when full_document include invalid class_name ' do
      let(:full_document) { '[clss: .button-filled--action]<a href="https://t.afi-b.com/visit" target="_blank" rel="nofollow noopener" class="">アンカーテキスト</a>' } # rubocop:disable Metrics/LineLength
      let(:expected_string) { '<a href="https://t.afi-b.com/visit" target="_blank" rel="nofollow noopener" class="">アンカーテキスト</a>' } # rubocop:disable Metrics/LineLength
      it { is_expected.to eq expected_string }
    end
  end
end
```

以上のようにして、

```txt
[class: l-margin--auto.l-flex--center.button-filled--action][アンカーテキスト](アンカーリンク)
```
と言った記法で、

```html
<a href=アンカーリンク class='l-margin--auto l-flex--center button-filled--action'>
  アンカーテキスト
</a>
```

となり、redcarpetデフォルトのリンクのマークダウンにクラスを指定する記法を追加できました。

# まとめ
きっかけは、上述の通り、レガシーなコードの改修を避けることが大きかったのですが、やってみると意外とスマートにできたかな思います。今後少しづつマークダウンでの本運用も視野に、編集部の方で入れてくれているので、引き続き改良を続けていこうと思います。
近々Rails６のaction textも実際使ってみようかと思うので、こちらもまた、レポートできたらと思います。
ではでは。