rollen.io

Rendering markdown views in Rails

2023-01-30

Rails is a fantastic tool for building web applications, with a large set of conventions and "batteries-included" libraries guiding the development. One area where Rails does not have great support though is for hosting static Markdown pages along with the rest of the application. Luckily, it's easy to hook into Rails' rendering flow to build out the functionality ourselves.

If you want to try this at home, here's a GitHub link to a demo application that follows the blog post below. Each commit corresponds to a section in the post.

Why?

There's many great ways to create and host static pages. This page is generated using zola, and hosted from an S3 bucket, with AWS cloudfront in front as a CDN. This setup is extremely easy to manage, and cheap. Costs are usually below $2 / month, and deployment is as simple as pushing to a git repo that synchronizes with the S3 bucket through a simple GitHub action.

Even so, I recently found myself wanting to host some static pages in my Rails application. For my startup, RoQR, I wanted to move my documentation from the docs.roqr.app subdomain to the roqr.app domain. I host my Rails app at the latter, and though I might do some routing magic to forward from one domain to the other, I would much prefer deploying the full stack as a monolith, documentation included.

Another reason to host your static pages along with your Rails application is so that you can simplify sharing state between the two. For example, I would like to have a button on my documentation pages that links to a login page if the user is not signed in, but to the web app if the user is signed in. That's much easier to do if Rails handles both the static pages and the app.

What I did not want to do is have to code my static pages using HTML, and so I set out to build out a solution to render my static pages from markdown files, using Rails. Here's how you can do the same.

Minimum viable markdown renderer

Let's start by getting a simple markdown file rendered and displayed in Rails. If you want to follow along, generate a new Rails1 project:

rails new rails-markdown-example --css=tailwind
cd rails-markdown-example

To render markdown files, we're going to need a gem to convert the markdown into HTML. You have a few choices here, but I'm going to go with redcarpet.

bundle add redcarpet

Now for the magic part: we hook into Rails' template handler system to register a handler for markdown files, and process those files with redcarpet:

# config/initializers/markdown.rb

# We define a module that can parse markdown to HTML with its `call` method
module MarkdownHandler
  def self.erb
    @erb ||= ActionView::Template.registered_template_handler(:erb)
  end

  def self.call(template, source)
    compiled_source = erb.call(template, source)
    "Redcarpet::Markdown.new(Redcarpet::Render::HTML.new).render(begin;#{compiled_source};end).html_safe"
  end
end

# Now we tell Rails to process any files with the `.md` extension using our new MarkdownHandler
ActionView::Template.register_template_handler :md, MarkdownHandler

We'll test it out by defining a simple controller and route:

# config/routes.rb

Rails.application.routes.draw do
  root 'pages#index'
end
# app/controllers/pages_controller.rb

class PagesController < ApplicationController
  def index; end
end

Finally, let's create our markdown file to render. Note that the extension is .html.md, not the usual .html.erb:

<!-- # app/views/pages/index.html.md -->

# Test

- This
- is 
- a
- list

Start your rails server using ./bin/dev2, and navigate to localhost:3000. Success! The markdown file is rendered in our browser:

Markdown file being rendered

It doesn't look very good though. This is because we're using tailwind to style our page, but we haven't defined any styles yet. Tailwind resets all styles using its preflight plugin by default, so our page is looking very plain.

We could define custom styles for our page, but tailwind also comes with a typography plugin with a set of prose classes that define good looking typographic defaults. Let's format our markdown using that plugin:

# app/views/layouts/application.html.erb
...
<main class="container mx-auto mt-28 px-5 flex">
- <%= yield %>
+  <article class="prose">
+    <%= yield %>
+  </article>
</main>
...
Nicer formatting using `prose`

That's much nicer.

Explore the code at this point

Using HighVoltage for routing

In the previous section, we defined the PagesController and an index route to register our markdown file at the root path. However, if we're planning on adding a lot of static files, we would quickly get overwhelmed by the amount of routes and controller actions we'd have to create.

Fortunately, there's another gem we can use that is designed to solve this specific issue: high_voltage. Lets refactor our app to use it:

bundle add high_voltage
rm app/controllers/pages_controller.rb
# config/initializers/high_voltage.rb
HighVoltage.configure do |config|
  config.home_page = 'index'
end
# config/routes.rb
Rails.application.routes.draw do
- root 'pages#index
end

Let's restart out Rails server and verify that everything still works by navigating to localhost:3000 again. It works.

Now, lets create one more markdown route to see how high_voltage deals with multiple files.

<!-- app/views/pages/blog/new_post.html.md -->

# New blog post

<small>Author: Seb</small>

This is some blog text

Let's also link to the blog post from our home page:

# app/views/pages/index.html.md
...
+ ## Blog
+ [Blog post](<%= page_path('blog/new_post') %>)
Index
Blog post

Explore the code at this point

Customize the route

Our page is looking nice, but I'm not a big fan of the generated path for our blog-post: pages/blog/new_post. There's two issues with it:

  1. The content is hosted under /pages, but for this example, I would rather it be under /docs
  2. The underscores in new_post are a bit of an eye-sore for a URL. new-post would be much nicer.

Let's tackle #1 first. We'll disable high_voltage's automatic routing and replace it with our own.

# config/initializers/high_voltage.rb

HighVoltage.configure do |config|
  config.home_page = 'index'
+ config.routes = false
end
# config/routes.rb

Rails.application.routes.draw do
+ get '/docs/*id' => 'pages#show', :as => :page, format => false
end

Easy as that. To tackle #2, we're going to have to bring back PagesController:

# app/controllers/pages_controller.rb

class PagesController < ApplicationController
  include HighVoltage::StaticPage

  private

  def page_finder_factory
    PageFinder
  end
end

Instead of relying on high_voltage's default page finder, we're going to provide our own:

# app/models/page_finder.rb

class PageFinder < HighVoltage::PageFinder
  def find
    paths = super.split('/')
    directory = paths[0..-2]
    filename = paths[-1].tr('-', '_')

    File.join(*directory, filename)
  end
end

This finder just takes the path and replaces any underscores with hyphens in the final URL segment.

Now both issues are fixed!

Explore the code at this point

Change layout based on the URL path

Our URL is looking good, but how about our blog page? What if we want the blog page to have a different layout from the rest of the pages?

We can define a custom layout in the pages controller based on our path:

# app/controllers/pages_controller.rb

class PagesController < ApplicationController
  include HighVoltage::StaticPage
+ layout :layout_for_page

  private

+ def resource
+   params[:id].split('/').first
+ end
+
+ def layout_for_page
+   case resource
+   when 'blog'
+     'pages/blog_post'
+   else
+     'application'
+   end
+ end
...

In words, any page under the path /docs/blog/ is going to be rendered using the pages/blog_post layout, and all other pages will use that standard application layout.

Let's define our new layout. Specifically, I want this layout to render the page in dark-mode:

<!-- app/views/layouts/pages/blog_post.html.erb -->

<!DOCTYPE html>
<html>
  <head>
    <title>RailsMarkdownExample</title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>
    <%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>
    <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
    <%= javascript_importmap_tags %>
  </head>

  <body class="bg-gray-900">
    <main class="container mx-auto mt-28 px-5 flex">
      <article class="prose prose-invert">
        <%= yield %>
      </article>
    </main>
  </body>
</html>

A few things to note here:

It looks like this:

Dark mode

Explore the code at this point

Parsing and rendering front-matter.

Many markdown converters use front-matter to embed certain metadata in the document. For example, instead of beginning all our blog posts with # Blog post title, we might choose to instead encode that using front-matter. Lets refactor our blog post to use YAML3 front-matter, and add a published date as an additional point of metadata.

<!-- app/views/pages/blog/new_post.html.md -->
- # New blog post
- 
- <small>Author: Seb</small>
+ ---
+ title: New blog post
+ author: Seb
+ date: 2023-01-30
+ ---
...

We'll add one more gem to help us separate the front-matter from the rest of the markdown content:

bundle add ruby_matter

We have to update our markdown renderer to ignore the front-matter in its normal course of rendering:

# config/initializers/markdown.rb
...
def self.call(template, source)
-  compiled_source = erb.call(template, source)
+  parser = RubyMatter.parse(source)
+  compiled_source = erb.call(template, parser.content)
   "Redcarpet::Markdown.new(Redcarpet::Render::HTML.new).render(begin;#{compiled_source};end).html_safe"
end
...

To extract the front-matter into a usable format, let's update our controller to parse the front-matter into an instance variable:

# app/controllers/pages_controller.rb

...
  layout :layout_for_page
+ before_action :extract_frontmatter
...

  private

+ def extract_frontmatter
+   @frontmatter = RubyMatter.parse(file_contents).data
+ end
+
+ def file_contents
+   File.read(File.join('app', 'views', "#{file}.html.md"))
+ end
+
+ def file
+   page_finder_factory.new(params[:id]).find
+ end
...

This almost work, but we run into an issue where the underlying library used for YAML parsing disallows values of Date and Time by default.

We can fix this by providing our own lambda for parsing YAML files, where we explicitly allow these classes:

# app/controllers/pages_controller.rb

...
  layout :layout_for_page
  before_action :extract_frontmatter

+ ENGINES = {
+   yaml: {
+     parse: ->(yaml) { Psych.safe_load(yaml, permitted_classes: [Date, Time]) },
+     stringify: ->(hash) { Psych.dump(hash).sub(/^---(\n|\s)?/, '') }
+   }
+ }.freeze
...

  def extract_front_matter
-   @frontmatter = RubyMatter.parse(file_contents).data
+   @frontmatter = RubyMatter.parse(file_contents, engines: ENGINES).data
  end
...

Now it works.

As a final step, let's render our extracted data in our layout:

<!-- app/views/layouts/pages/blog_post.html.erb -->
...
  <article class="prose prose-invert">
+   <h1><%= @frontmatter['title' %></h1>
+   <p>
+     Posted by <%= @frontmatter['author'] %> on <%= @frontmatter['date'] %>
+   </p
...
Blog post rendered with front-matter

Excellent

Explore the code at this point

Conclusion

So that does it: a bona-fide markdown-to-html workflow including front-matter in Rails. Do you have any suggested improvements to this approach? Let me know!


[1] I'm using Rails version 7.0.4.2. You might run into some issues following this post if your version is significantly different. ↩︎

[2] We're using ./bin/dev rather than the normal rails s as we're running more than one process here using foreman: the web server and a process for redefining our CSS using Tailwind. You can learn more about how that works here ↩︎

[3] Sorry, Norway! ↩︎

Comment via email Back to main page