edruder.com i write about anything, or nothing

Adding support for Markdown views to Rails 5

I 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 publiccall 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 renderbegin;#{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.