Frontmatter on Rails
Add Frontmatter to Rails and use structured data in your views. We'll customize ActionView, add our own ActiveRecord-like objects, and build a small app.
If you've used static site generators like Middleman and Jekyll, you might be familiar with Frontmatter — a block of structured data at the beginning of a template. In this post we'll add it to Rails, and build a small site in the process.
I was doing some work on a Rails app that had a bunch of static pages that needed an index page. I didn’t want to update the index every time a new static page was added, and having a database table meant keeping two sources of data in sync. Frontmatter would be perfect as it would let me add structured data I need (date, title, summary, categories etc) directly to the templates. Since the pages are in the git repository, there’s nothing to sync between dev and production environments. Unfortunately Rails does not support this out of the box, but luckily it’s fairly simple to add.
---
title: Frontmatter on Rails
summary: Let's add support for Frontmatter to our Rails app!
published: true
---
Clone the repository at github.com/anderssvendal/frontmatter-on-rails, and follow along from the comfort of your own editor.
Setup
To get started let's create a new Rails app and install the ruby_matter
gem.
> rails new frontmatter-on-rails
# In the directory of the new app
> bundle add ruby_matter
# I like to use slim for templates, so I'll add that too
> bundle add slim
Add some content
Our site will be about programming languages, so we'll need a controller for that:
rails g controller ProgrammingLanguages index
Now we'll create templates for some programming languages in app/views/programming_languages/
. Here is one example for Assembly:
---
name: Assembly
first_introduced: 1947
---
h1 Assembly
p In computer programming, assembly language, often referred to simply as assembly and commonly abbreviated as ASM or asm, is any low-level programming language with a very strong correspondence between the instructions in the language and the architecture's machine code instructions.
Download a few more from here, or add your own. I got all the content from Wikipedia.
When you have a few templates in place update the following files:
config/routes.rb
Rails.application.routes.draw do
get '/:language', to: 'programming_languages#show'
root "programming_languages#index"
end
app/controllers/programming_languages_controller.rb
class ProgrammingLanguagesController < ApplicationController
def index
end
def show
render params[:language]
end
end
The basics are now ready, but you'll notice none of the pages work, and the show action simply tries renders a template named whatever is in the url. We'll fix all of that starting with making the pages work.
Ignore Frontmatter
To make the pages render we have to remove any Frontmatter blocks from templates before they're rendered normally. We'll create our own template handler at lib/strip_frontmatter_template_handler.rb
.
Our template handler will not do any rendering of its own. It will be a wrapper around a regular one, simply removing the Frontmatter part from source
before rendering. The call method is how Rails expect template handlers to work, so that’s how it must be.
class StripFrontmatterTemplateHandler
attr_reader :handler
def initialize(handler)
@handler = handler
end
def call(template, source)
# Extract the non-frontmatter part of the template
source = RubyMatter.parse(source).content
# Pass the extracted source to the original handler
handler.call(template, source)
end
end
In a new initializer at config/initializers/actionview.rb
we use ActiveSupport.on_load hook to replace the regular template handlers with our Frontmatter-removing one.
ActiveSupport.on_load(:action_view) do
[
:erb,
:slim
].each do |extension|
handler = ActionView::Template.registered_template_handler(extension)
ActionView::Template.register_template_handler(
extension,
StripFrontmatterTemplateHandler.new(handler)
)
end
end
Consume Frontmatter
Now that our templates are properly rendered it's time to use the Frontmatter part of the templates. Create a class called ProgrammingLanguage at app/models/programming_language.rb
.
This class will find the pages we created earlier, parse the Frontmatter and provide an ActiveRecord-like api for interacting with the templates.
class ProgrammingLanguage
class << self
def all
# Find all the programming languages and sort them, newest first
Dir.children(dirname)
.filter { programming_language_template?(_1) }
.map { dirname.join(_1) }
.map { ProgrammingLanguage.new(_1) }
.sort
.reverse
end
def find(slug)
all.find do |programming_language|
programming_language.slug == slug
end
end
def find!(slug)
find(slug) || raise_record_not_found
end
private
def dirname
# Where all the templates are placed
Rails.root.join('app/views/programming_languages')
end
def programming_language_template?(filename)
# Do not treat templates for regular actions as programming languages
[
'index.',
'show.',
'edit.',
'new.'
].none? do |reserved|
filename.starts_with?(reserved)
end
end
def raise_record_not_found
raise ActiveRecord::RecordNotFound
end
end
attr_accessor :pathname
delegate :content, :data, to: :frontmatter, prefix: true
def initialize(pathname)
@pathname = pathname
end
def slug
pathname.basename.to_s.split('.').first
end
def path
"/#{slug}"
end
def name
frontmatter_data['name']
end
def first_introduced
frontmatter_data['first_introduced']
end
def <=>(other)
# Can only be compare with other ProgrammingLanguage instances
return nil unless other.is_a?(self.class)
# Compare name if both languages was introduced the same year
return name <=> other.name if first_introduced == other.first_introduced
# Age before beauty
first_introduced <=> other.first_introduced
end
private
def frontmatter
# Parse templates with RubyMatter
@frontmatter ||= RubyMatter.read(pathname)
end
end
Go ahead and check out the ProgrammingLanguage
class in the console. It's not super exciting yet, but you should get a good idea of how to add more things.
Let's put our new tech to good use by making the homepage list all the programming languages.
class ProgrammingLanguagesController < ApplicationController
def index
render locals: {
programming_languages: ProgrammingLanguage.all
}
end
def show
programming_language = ProgrammingLanguage.find!(params[:language])
render programming_language.slug
end
end
app/views/programming_languages/index.html.slim
h1 Programming languages:
ol
- programming_languages.each do |programming_language|
li
= link_to(programming_language.name, programming_language.path)
Taking it further
Now that this is working like planned, I want to do a few more things.
Make it prettier
Take a look at the changes on GitHub
Next I wanted to make it look a bit better, so I did that. I added some helper tags and classes and some basic styling. Nothing too fancy.
Add more content
Take a look at the changes on GitHub
It's looking pretty good, so let's finish up by add some more attributes to our templates.
I asked ChatGPT for a tagline for each programming language and added that. Instead of adding a new getter method I create those methods using an array of property names and define_method
. This also include a presence method for each property, like in ActiveRecord.
The page_title
method uses the property from frontmatter_data
if present or creates a fallback using name
. ChatGPT gave me some suggestions for this to, which I added to the Ruby and Swift templates.
Here are the changes needed to make this work:
app/models/programming_language.rb
class ProgrammingLanguage
# ...
[
'first_introduced',
'name',
'tagline',
].each do |attribute|
# Add a getter and presence? method for the frontmatter attributes we use
define_method(attribute) do
frontmatter_data[attribute]
end
define_method("#{attribute}?") do
send(attribute).present?
end
end
def page_title
frontmatter_data['page_title'].presence || "The #{name} Progamming Language"
end
# ...
end
Add the default page_title to app/controllers/application_controller.rb
and make it a helper method the layout can use.
class ApplicationController < ActionController::Base
helper_method :page_title
def page_title
'Programming languages'
end
end
app/controllers/programming_languages_controller.rb
overrides page_title to use the value from ProgrammingLanuage when available.
class ProgrammingLanguagesController < ApplicationController
# …index unchanged
def show
raise ActiveRecord::RecordNotFound unless programming_language?
# Render the file for the programming language
render programming_language.slug
end
private
def programming_language
return nil unless params[:language].present?
ProgrammingLanguage.find(params[:language])
end
def programming_language?
programming_language.present?
end
def page_title
programming_language.try(:page_title) || super
end
end
Wrapping up
This was a fun little project, but all good things must come to an end. I have a few more improvements in mind that I won’t do now. Maybe I’ll get to expand for a project at some point.
Feel free to open an issue in the Github repository if you have feedback or questions. And if you’ve implemented improvements of your own above, open a PR!
Update: After sharing this post on Ruby on Rails Link I was made aware of a gem that does pretty much the same thing called Frontmatter made by Caleb Hearth. Check it out over on GitHub.