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/dev
2, and navigate to localhost:3000
. Success! The markdown file is rendered in our browser:
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>
...
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') %>)
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:
- The content is hosted under
/pages
, but for this example, I would rather it be under/docs
- 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:
- I styled the whole page with a dark background using
class="bg-gray-900"
- I inverted all the text colors by adding
prose-invert
to my<article>
classes.
It looks like this:
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
...
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! ↩︎