Sometimes it's useful to be able to store ERB templates in your database. Say you want to want to have email templates that are editable by admins, or you want to create blog posts that are editable (like this site has) and want access to route helpers or just the full power of ruby in those HTML templates. There doesn't seem to a proper "Rails way" of doing this, but it's a pretty common need. So, here's what I do:
First, I install another erb engine that's more customizable [1]. Don't worry, Rails will still use its default ERB engine for rendering template files from disk. This gem will just be used to solve our problem.
Gemfile:
gem "erubis" # I tested it with 2.7.0 but it's a stable gem so I think most versions will work
Then we need a custom "context" class for erubis
. The context object is where we pass variables we want to access from the template.
lib/my_erb_context.rb
class MyErbContext < Erubis::Context
include Rails.application.routes.url_helpers
# allow `url_for` in the erb template.
include ActionView::RoutingUrlFor
def initialize(vars = {})
super()
vars.each do |var, val|
self[var] = val # allow it to be accessed in the template as an instance variable
self.singleton_class.attr_reader(var) # allow it to be accessed as a reader method
end
end
# The rest of these methods are needed to be able to use named routes
def routes
Rails.application.routes
end
def controller
end
def default_url_options
{ only_path: false }
end
end
Now, to put it all together:
template_source = <<SRC
<p>It's <%= @time_now.strftime("%H:%M:%S") %></p><!-- instance var -->
<p>In 5 hours it will be <%= (time_now + 5.hours).strftime("%H:%M:%S") %></p><!-- reader method -->
<a href="<%= new_session_path %>">Login here!</a><!-- routes -->
SRC
template = Erubis::Eruby.new(template_source)
context = MyErbContext.new(time_now: Time.zone.now) # pass in whatever you want
output = template.evaluate(context)
We could create a helper method for formatting time in this example and either add that
method in the context class, pass a lambda into the input vars of the initializer or extend the context
object with a method after initialization. Let's say we want to access the link_to
helper method to generate an a
tag. For that, we just need to include the proper module into the context class, in this case it's ActionView::Helpers::UrlHelper.
Want all the helper methods available to regular Rails templates? Include ActionView::Helpers itself.
Bonus Round
Okay, so we can access variables and routes from the templates. But what about partials? Well,
here it starts to get a bit messy. I'm sure there are cleaner solutions, but here's mine after
playing around with it for 30 minutes and reading some ActionView
source code. So remember, we're
trying to get render(partial: 'some_partial', locals: { }) working inside the template.
We add the following to the initialize
method:
params = vars.delete(:params) || {}
# vars.each { ... }
lookup_context = ActionView::LookupContext.new(
Dir.glob(Rails.root.join('app', 'views', '*'))
)
# this object is solely responsible for `render` calls
@base = MyViewClass.new(lookup_context, vars.dup, controller=nil, self, params)
Here's that new class:
# I namespace this inside MyErbContext
class MyViewClass < ActionView::Base
def initialize(lookup_context, assigns, controller, erb_context, params = {})
super(lookup_context, assigns, controller)
@_my_erb_context = erb_context
@_my_params = params
end
# implementation detail, needed
def compiled_method_container
singleton_class
end
# in case we access params inside the partial
def controller
fake = Object.new
params = @_my_params
fake.define_singleton_method(:params) do
params
end
fake
end
# all the route methods and other helpers we include in the Context class
# get delegated back to the context object here
def method_missing(name, *args, &block)
if @_my_erb_context.respond_to?(name, true)
@_my_erb_context.send(name, *args, &block)
else
super
end
end
end
And to hook it up:
class MyErbContext
delegate :render, to: :@base
end
I tested this on Rails 7.2.2
, but it will work with older versions of Rails, it just might need a bit of tweaking.
Finally, remember that regular users should never be able to execute arbitrary code
on your web server, so only save ERB templates to the database that a trusted user (an admin)
has created.
[1]
As I'm writing this the default Rails ERB engine is erubi
, which I didn't really try to get working, admittedly.
It may be just as customizable, but I'm used to erubis
and erubi
lacks documentation at the moment.