Рефакторинг, метапрограммирование и эпичное редактирование markdown
Я активно использую 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, но скорее всего какая-то его версия у вас уже установлена.
Итак, чтобы не запутаться, повторю уже сказанное:
- Заводим два поля – для markdown и html версий
- Перед сохранением каждой записи генерируем из markdown html версию
- За генерацию отвечает гем 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-текстов