Adding support for Markdown views to Rails 5
19 Dec 2017I thought that adding Markdown support to a Ruby on Rails 5 project would be a straightforward thing. I’ve read a bunch of blogs over the years that described various ways to use Markdown in Rails apps. There are many Markdown gems–redcarpet, kramdown, rdiscount, to name a few. And there are several ways to use Markdown–from allowing a content management system to author new posts in Markdown to supporting Markdown-formatted views in a Rails app itself. I was interested in the latter, and expected it to be pretty easy.
Wrong.
My first road block was conceptual. I’ve been using Rails for a long time, off
and on, but haven’t used it consistently since pre-Rails 3. My recollection,
which may be just wrong, is that at some point, Rails’ view rendering allowed
chaining of several template renderers–if you named your view something like,
posts.html.md.erb
, and you had the handlers installed, the view source would
first be handled by the ERB handler, then the Markdown handler, then
Rails’ standard HTML view processing. Turns out, this is not the way that
Rails’ template handlers work (and they may never have worked that way).
As far as I can figure out, ActionView
template handlers aren’t chainable–
they are intended to convert from one type of template (ERB
or Markdown
or
Haml
, say) into the final MIME
type that your template is targeting (HTML
or JSON
or XML
, for example), and no more. If you want to create views in
Markdown that can have ERB inside of them, then your Markdown handler needs to
know how to handle ERB itself.
I searched all over the Web and found several helpful blog posts, but none that did exactly what I wanted (a few came pretty close, though). The Rails Guides don’t discuss how to create your own template handlers, and even the Rails source wasn’t much help to me. I wanted a clean initializer and I wanted to enable ERB in the Markdown templates.
A Solution
I chose to use the redcarpet
Markdown gem because it’s fast, feature-rich,
and well-maintained. redcarpet
needs to be available in all of the Rails
app’s environments, so don’t put it into a development
-only stanza of your
Gemfile
! (Been there, done that.)
I like to use TDD (though I’m not super-consistent about it), and it’s easy to create a test for this–just create a view in Markdown and verify that the HTML that’s rendered contains the right tag(s)! I’m using RSpec, and my test looks like this:
# spec/views/welcome/about.html.md_spec.rb
require 'rails_helper'
RSpec.describe 'welcome/about.html.md' do
it 'renders Markdown' do
render
expect(rendered).to match %r{<h3>About My Website</h3>}
end
end
It will fail, of course, because there is no such view.
Then, create the Markdown view:
<!-- app/views/welcome/about.html.md -->
### About My Website
It's really cool!
The spec will still fail, but in a different way, since now the template is asking for a handler that doesn’t exist.
Finally, the Markdown handler!
# config/initializers/redcarpet.rb
require 'redcarpet'
module ActionView
module Template::Handlers
class Markdown
class_attribute :default_format
self.default_format = Mime[:html]
class << self
def call(template)
compiled_source = erb.call(template)
"#{name}.render(begin;#{compiled_source};end)"
end
def render(template)
markdown.render(template).html_safe
end
private
def md_options
@md_options ||= {
autolink: true,
fenced_code_blocks: true,
strikethrough: true,
tables: true,
}
end
def markdown
@markdown ||= Redcarpet::Markdown.new(HTMLWithPants, md_options)
end
def erb
@erb ||= ActionView::Template.registered_template_handler(:erb)
end
end
end
end
end
class HTMLWithPants < Redcarpet::Render::HTML
include Redcarpet::Render::SmartyPants
end
ActionView::Template.register_template_handler(:md, ActionView::Template::Handlers::Markdown)
A little explanation, starting from the bottom.
ActionView::Template.register_template_handler
is the method that registers a
new template handler. It expects the filename extension that the handler
applies to (:md
, for files that end in .md
), and an object that implements
a call
method–in our case, the class that we defined,
ActionView::Template::Handlers::Markdown
.
The class << self
block wraps the class methods, of which only two are
public
–call
and render
. (render
only needs to be public because it’s
going to be called by Rails, which we’ll see in a second.) The magic happens in
the call
class method–the class itself implements the call
method that
register_template_handler
requires.
The call
method needs to return a string containing the code that needs
to be executed by Rails to generate HTML (in this template handler, because of
the self.default_format = Mime[:html]
line). (There isn’t much, if any,
documentation on this–I got this from the articles I found online.)
The compiled_source = erb.call(template)
line is what handles ERB in the
Markdown template. The string it returns is then handled by the Markdown
renderer that’s in our code.
"#{name}.render(begin;#{compiled_source};end)"
is the return value of the
call
method–that’s the string of code that Rails will eventually evaluate
and run. Because the code will be run in a Rails context, it needs to
fully-qualify the method to be run. The name
method returns the full name of
self
, which is the class, so it returns
ActionView::Template::Handlers::Markdown
. #{name}.render
, then,
fully-qualifies our render
method.
The parameter to render
–begin;#{compiled_source};end
–is also mysterious.
It wraps a Ruby block around the string that the ERB handler returned (that is
also Ruby code), which makes it digestible to the Markdown render
method.
I’m still a little fuzzy on why the code that invokes the handler needs to be passed around as a Ruby string, but this solution works nicely for me!
Please leave a note if you have questions, suggestions, or a better explanation.