Рефакторинг, метапрограммирование и эпичное редактирование markdown

Illustration of a person sitting on the floor against a wall, absorbed in reading a tablet, with a focus on comfort and relaxation, featuring a minimalist color palette. Illustration of a person sitting on the floor against a wall, absorbed in reading a tablet, with a focus on comfort and relaxation, featuring a minimalist color palette.

Я активно использую markdown на mkdev: тексты заданий, тексты блог постов и даже тексты отчётов к заданиям поддерживают разметку при помощи markdown и хранятся в базе данных именно в этом формате. Естественно, в браузер необходимо отдавать html код, а не markdown, поэтому должен быть способ перегонять markdown в html перед выводом пользователю. Есть несколько вариантов сделать это:

  • превращать markdown в html прямо на клиенте, при помощи javascript библиотеки (например, markdown.js)
  • хранить в базе и markdown и html версии текстов

Идея преобразовывать на лету в браузере плоха тем, что для действительно больших статей это приведёт к увеличенному времени загрузки и, возможно, визуальным глюкам во время обработки текста. Поэтому я выбрал второй вариант, который используется и на fodoj.com и здесь, на mkdev. Изначальная реализация была написана в пределах fodoj.com, и потом грубым образом перенесена в код mkdev. В результате возникла дубликация кода: рендеринг html происходил одним образом аж в трёх разных моделях и всё ещё не был вынесен в отдельный класс.

Этим и займёмся сегодня: заDRYим обработку markdown, рассмотрим как она реализована и узнаем как сделать редактирование markdown в браузере чуть более удобным.

Изначальная реализация

Для каждого поля, который форматируется в markdown, мы добавляем ещё одно, которое будет хранить html-версию. Возьмём, к примеру, модель Post, в которой есть поля body (для html) и body_raw (для markdown). Если у вас возник вопрос "почему не body_html и body_markdown", то я отвечу на него одним словом – "legacy" :-)

# Подключаем необходимые библиотеки
require 'redcarpet'
require 'syntax_highlighter'

class Post < ActiveRecord::Base
  extend FriendlyId
  friendly_id :title, use: :slugged

  # Перед сохранением вызываем рендеринг "body"
  before_save do
    render_attr("body")
  end

  belongs_to :category
  belongs_to :author, class_name: "User"

  validates :body_raw, :title, :category_id, presence: true

  scope :regular, -> { where(premium: false) }
  scope :premium, -> { where(premium: true) }

  private

  # DRY
  def render_attr(attr)
    # Используем наш самописный класс для рендеринга, я вернусь к нему позже
    renderer = SyntaxHighlighter

    # Указываем пару опций для Redcarpet, например, чтобы ссылки автоматически превращались в <a> теги
    extensions = { fenced_code_blocks: true, autolink: true }
    redcarpet = Redcarpet::Markdown.new(renderer, extensions)

    # Ну и, собственно, записываем новое значение в поле body
    self.send("#{attr}=", redcarpet.render(self.send("#{attr}_raw")))
  end
end

Вроде всё просто. Осталось понять что это за Redcarpet и SyntaxHighlighter. Redcarpet – самая быстрая библиотека для работы с markdown в ruby. Легко расширяется своими методами рендеринга, один из которых, SyntaxHighlighter, мы и используем. Заглянем в код SyntaxHighlighter

# Наследуемся от уже существующего класса для рендеринга html
class SyntaxHighlighter < Redcarpet::Render::HTML

  # И переписываем метод block_code так, чтобы он раскрашивал куски кода при помощи библиотеки pygmentize
  def block_code(code, language)
    require 'pygmentize'
    Pygmentize.process(code, language)
  end
end

Pygmentize – библиотека для Pygments, одного из самых популярных раскрашивателей кусков кода. Требует установленного на машине Python, но скорее всего какая-то его версия у вас уже установлена.

Итак, чтобы не запутаться, повторю уже сказанное:

  1. Заводим два поля – для markdown и html версий
  2. Перед сохранением каждой записи генерируем из markdown html версию
  3. За генерацию отвечает гем Redcarpet, который дополнительно использует самописную подсветку синтаксиса

ActiveSupport::Concern

Как я уже сказал раньше, нам необходимо вынести общий для нескольких моделей код. При этом нам потребуется некий способ указывать какие именно поля конкретной модели должны подвергаться конвертации из markdown в html. Значит, нам нужен метод класса, который будет принимать названия необходимых полей, а затем определять для этих полей конвертацию.

Лучше всего для выноса общей функциональности для классов в Ruby подходят модули. В Rails есть нечто под названием concerns, лежат в папке app/models/concerns. Это крошечное расширение стандартных модулей в Ruby, которое лучше разрешает зависимости между модулями и предоставляет чуть более приятный DSL.

Создадим модуль RichFormatting:

# app/models/concerns/rich_formatting.rb
require 'redcarpet'
require 'syntax_highlighter'

module RichFormatting
  extend ActiveSupport::Concern

  module ClassMethods

  end

end

Теперь можем написать метод класса, отвечающий за определение новых setter'ов для полей. Назовём его formatted_fields. Нам, нужно переписать setter для каждого форматированного поля так, чтобы он не только задавал значение самого поля, но и прописывал в html поле сконвертированную версию. Выглядит это вот так:

# app/models/concerns/rich_formatting.rb
# ...
  module ClassMethods

    # В качестве аргумента принимаем Hash с полями, где ключ это markdown поле, а значение - html поле
    def formatted_fields(fields)

      # Переписываем setter для каждого markdown поля
      fields.each do |formatted_text_field, markdown_text_field|
        define_method "#{markdown_text_field}=" do |markdown_text|

          # Сначала записываем оригинальное значение в markdowon поле
          write_attribute(markdown_text_field, markdown_text)

          # Затем конвертируем это значение в html и записываем в html поле
          write_attribute(formatted_text_field, markdown_to_html(markdown_text))
        end
      end

    end
  end
# ...

Остаётся написать метод markdown_to_html, который является практически полной копией ранее используемого render_attr. Весь модуль теперь выглядит так:`

# app/models/concerns/rich_formatting.rb
require 'redcarpet'
require 'syntax_highlighter'

module RichFormatting
  extend ActiveSupport::Concern

  module ClassMethods

    def formatted_fields(fields)

      fields.each do |formatted_text_field, markdown_text_field|
        define_method "#{markdown_text_field}=" do |markdown_text|
          write_attribute(markdown_text_field, markdown_text)
          write_attribute(formatted_text_field, markdown_to_html(markdown_text))
        end
      end

    end

  end

  def markdown_to_html(markdown_text)
    renderer = SyntaxHighlighter
    extensions = { fenced_code_blocks: true, autolink: true }
    redcarpet = Redcarpet::Markdown.new(renderer, extensions)
    redcarpet.render(markdown_text)
  end

end

И это правда всё, что нам нужно. Теперь перепишем модель Post с учётом нового модуля:

class Post < ActiveRecord::Base
  include RichFormatting # Подключаем наш новый модуль
  extend FriendlyId
  friendly_id :title, use: :slugged

  belongs_to :category
  belongs_to :author, class_name: "User"

  validates :body_raw, :title, :category_id, presence: true

  scope :regular, -> { where(premium: false) }
  scope :premium, -> { where(premium: true) }

  formatted_fields body: :body_raw # Указываем что нужно в поле body конвертировать содержимое body_raw

end

Теперь каждый раз при изменении поля body_raw будет изменяться и поле body. Но мы забыли кое-что.

Тесты

Так как у нас аж две новых сущности – модуль RichFormatting и класс SyntaxHighliter, задействованных при сохранении записей, то не помешает написать хотя бы один тест, проверяющий что всё работает как надо. Не будем сходить с ума, проверим лишь что после сохранения блог поста у него обновляется html поле с правильной разметкой.

# spec/models/post_spec.rb

require 'rails_helper'

describe Post do

  let!(:post) { create(:post) }

  it "renders html body" do
    post.update_attribute(:body_raw, "### Hello!")
    expect(post.body).to eql "<h3>Hello!</h3>\n"
  end

end

Удобное редактирование markdown текста

Не смотря на то, что одно из преимуществ использования markdown заключается как раз таки в улучшенной читаемости форматированного текста, иногда хочется увидеть как итоговый текст будет выглядеть после сохранения. Мне хотелось наиболее простого решения, позволяющего по-прежнему редактировать только markdown, без сложных визуальных редакторов, но при этом с возможностью посмотреть превью текста до сохранения.

С этой задачей идеально справляется javascript библиотека EpicEditor, которую мы используем в mkdev для всех больших текстов. Она позволяет по-быстрому посмотреть как будет выглядеть итоговый текст, а так же поддерживает полноэкранный режим, в котором легче сосредоточиться на тексте.

Чтобы подключить его к Rails-приложению я использовал гем epic-editor-rails, предоставляющий необходимые файлы.

Дальше я подключил саму библиотеку в app/assets/javascripts/application.js, добавив эту строчку: //= require epiceditor.

Следующий кусок javascript кода (временно расположенный в том же application.js) находит на странице textarea с id epiceditor_textarea и навешивает на него редактор. Для превью мы указываем путь к application.css приложения.

$(function() {
  if ($("#epiceditor_textarea").length > 0) {
    var editor = new EpicEditor({
      textarea: "epiceditor_textarea",
      theme: {
        preview: '/assets/application.css'
      }
    }).load();
  }
});

Во вьюхе с формой кусок отвечающий за epiceditor выглядит следующим образом:

  <section class="blog-post">
    <div id="epiceditor">
    </div>
  </section>

  <%= f.input_field :body_raw, id: "epiceditor_textarea" %>

Очень классный и простой редактор, идеально подходящий под нужды проекта.

Что мы узнали в пределах этой статьи:

  • Как заDRYить код в моделях при помощи ActiveSupport::Concern
  • Как динамически определять методы
  • Как конвертировать markdown текст в html и хранить в базе
  • Как покрыть это всё тестами
  • Как подключить и настроить удобный и простой браузерный редактор markdown-текстов