Catch Broken HTML Before Deploy with Rails' New Herb Template Engine

Note: Herb support is coming in Rails 8.2. It is merged to main but not yet released. The relevant pull requests are #58552 (the engine), #58721 (the framework default) and #58770 (the herb:check task).

The demo code for this tutorial is located at https://github.com/Code-With-Rails/herb-demo. The main branch has the fixed templates, and the broken branch has the broken ones so you can run bin/rails herb:check and see it fail.


Rails 8.2 adds a new way to compile your ERB templates. Instead of Erubi, HTML templates can now go through Herb, an ERB engine written by Marco Roth that understands HTML as well as Ruby. If a template has an unclosed tag, Herb will refuse to compile it and tell you the file and line.

In this article, I’m going to go over how this works, and then we will add a check to our CI so that a template with broken markup fails the build.

What We Are Building

We will start with a small Rails app that lists projects. It has a couple of partials, and some of them will have mistakes in their HTML.

By the end, we will have:

  • Herb compiling our HTML templates and raising errors for broken markup
  • A bin/rails herb:check step in bin/ci that checks every template in the app, including the ones our tests do not render

How Herb Is Different from Erubi

Up until now, Rails has compiled ERB templates with Erubi. Erubi treats a template as plain text with Ruby code mixed in. It does not know anything about HTML, so a missing </div> is not an error as far as it is concerned. The browser receives the broken markup and does its best to fix it.

Herb parses the HTML and the ERB together into one syntax tree. If a tag is opened and never closed, the tree is invalid and the template does not compile. For templates that are valid, the rendered output is the same as with Erubi.

There are three parts to the change:

  • config.action_view.erb_implementation can be set to :erubi, :herb, or a class.
  • config.load_defaults 8.2 sets it to :herb. Only templates with the html format use Herb. Other formats, such as text, JSON and Turbo Stream, continue to use Erubi.
  • A new bin/rails herb:check task compiles every HTML+ERB template through Herb and exits with a non-zero status if any of them fail.

Herb is already a dependency of Action View, so there is no gem to add.

Setting Up the App

Our controller is simple:

# app/controllers/projects_controller.rb
class ProjectsController < ApplicationController
  def index
    @projects = Project.all
  end
end

The index view renders a partial for each project:

<%# app/views/projects/index.html.erb %>
<h1>Projects</h1>

<ul class="projects">
  <%= render @projects %>
</ul>

And here is the partial. Notice that the div with the project-header class is never closed:

<%# app/views/projects/_project.html.erb %>
<li class="project">
  <div class="project-header">
    <h2><%= project.name %></h2>
    <%= render "shared/status_badge", status: project.status %>

  <% if project.archived %>
    <p class="muted">Archived</p>
  <% end %>
</li>

With Erubi, this page renders without any complaint. With Herb, visiting the page raises an ActionView::SyntaxErrorInTemplate error like so:

✘ [MissingClosingTagError] Opening tag `<div>` at (3:3) doesn't have a matching
closing tag `</div>` in the same scope.

    [source]:3:3:
      3 │   <div class="project-header">
        ╵   ~~~~~~~~~~~~~~~~~~~~~~~~~~~~

  Add the closing tag, or make it self-closing.

To fix it, we add the </div> after the status badge.

New applications get Herb through load_defaults 8.2. If you are upgrading an existing application, you can enable it in config/initializers/new_framework_defaults_8_2.rb by uncommenting this line:

Rails.application.config.action_view.erb_implementation = :herb

I recommend running herb:check (more on this below) before you do this.

Why Tests Are Not Enough

There is one thing to be aware of. Rails compiles a template the first time it is rendered. This means Herb can only report a problem in a template when something actually renders that template.

I ran into this while writing the demo app. With the broken partial above, this integration test still passed:

# test/integration/projects_test.rb
class ProjectsTest < ActionDispatch::IntegrationTest
  test "lists projects" do
    get projects_path
    assert_response :success
  end
end

The test database did not have any projects, so render @projects rendered nothing, and _project.html.erb was never compiled. The same thing can happen with a partial that is only shown behind a condition.

This is where herb:check comes in. It goes through every HTML+ERB template in your view paths (including variants, locales and templates from engines) and compiles each one through Herb. It also checks that the compiled Ruby is valid. The task works even if your app is still using Erubi, so you can also use it to find the templates you need to fix before switching.

Adding the Check to CI

Rails 8.1 applications come with bin/ci, which is configured in config/ci.rb. We will add a step for Herb to the “Checks” group:

# config/ci.rb
CI.run do
  step "Setup", "bin/setup --skip-server"

  group "Checks", parallel: 2 do
    step "Templates: Herb", "bin/rails herb:check"

    group "Tests" do
      step "Tests: Rails", "bin/rails test"
      step "Tests: Seeds", "env RAILS_ENV=test bin/rails db:seed:replant"
    end
  end
end

If you use GitHub Actions without bin/ci, you can add a step that runs bin/rails herb:check instead.

Trying It Out

To see what the check reports, I added two more broken partials to the demo app. In the first one, the closing tags are in the wrong order:

<%# app/views/shared/_status_badge.html.erb %>
<span class="badge">
  <strong><%= status.titleize %></span>
</strong>

In the second one, an ERB output tag is used inside the opening div tag to add an attribute:

<%# app/views/shared/_flash.html.erb %>
<div class="flash" <%= "hidden" if notice.blank? %>>
  <%= notice %>
</div>

Let’s run the check:

$ bin/rails herb:check
app/views/projects/_project.html.erb:
  [source]:3:3: Opening tag `<div>` at (3:3) doesn't have a matching closing tag `</div>` in the same scope.

app/views/shared/_flash.html.erb:
  2:19 - ERB output tags (<%= %>) are not allowed in attribute position. - Suggestion: Use control flow (<% %>) with static attributes instead.

app/views/shared/_status_badge.html.erb:
  [source]:3:3: Opening tag `<strong>` at (3:3) doesn't have a matching closing tag `</strong>` in the same scope. (and 1 more error)

3 HTML+ERB templates failed to compile through Herb.

The task exits with a status of 1, so bin/ci marks the Herb step as failed even though the tests in the same group pass.

The flash partial is worth explaining. Its HTML is valid when it renders, but herb:check also runs Herb’s security validator, which does not allow ERB output in the attribute position of a tag. HTML escaping takes care of quotes and angle brackets, but a value such as onclick=alert(1) would still add a new attribute to the tag. The fix is to use tag.attributes, which has been available since Rails 7.0:

<%# app/views/shared/_flash.html.erb %>
<div class="flash" <%= tag.attributes(hidden: notice.blank?) %>>
  <%= notice %>
</div>

When notice is present, the hidden attribute is left out. When it is blank, it renders as hidden="hidden".

After fixing all three partials, we run the check again:

$ bin/rails herb:check
All HTML+ERB templates compile through Herb. All is good!

Taking It Further

Here are a couple of ideas if you want to take this further:

  • Run the check before pushing. On the demo app, herb:check finished in under a second, so it is fast enough for a pre-commit or pre-push hook.
  • Migrate gradually. For a larger application, you can keep erb_implementation = :erubi, run herb:check, and fix the reported templates over several pull requests. Once the list is empty, switch to :herb and keep the CI step so new problems do not get in.

Conclusion

Herb lets Rails treat broken HTML in a template as an error, instead of leaving it for the browser to sort out. Combined with bin/rails herb:check, we can check every template in the app as part of CI, including the ones our tests do not reach.

If you would like to read the implementation, have a look at the pull requests: #58552, #58721 and #58770.