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.

Anders, developer

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

See these changes on GitHub

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.

Slim does not like Frontmatter yet

Ignore Frontmatter

See these changes on GitHub

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

See these changes on GitHub

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)

Now we're talking

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.