Tip: Dynamic user content in Rails with Liquid tags
When building features that accept user-generated content, you may need to display dynamic content based on what the user specifies. Imagine you want to users to be able to customize a welcome message sent from your application when they invite someone to their account.
Rails programmers are deeply familiar with writing content with pieces of dynamic text: we do this all the time when writing view templates. But we don’t want to allow users to write ERB or HAML strings and execute them in our app. It’s both a huge security risk and also not super friendly for users to have to learn a complete programming language to change some text.
An alternative might be use some “magic strings” where you can swap in values for special strings like $NAME
or *|EMAIL|*
. These are sometimes called “merge tags”. But implementing this approach usually ends up with a soup of gsub
, regular expressions, and weird edge-cases.
A great option for building this feature is to use Liquid templates. Liquid is a very striped down templating language that is most commonly used by Shopify for allowing store owners to customize their ecommerce stores.
Liquid templates solves all these issues.
There is security because you control the context used when rendering – this is a fancy way of saying that Liquid templates can only access data that you explicitly pass in. The syntax is minimal and it comes with built-in functions for common operations (default values, capitalization, date formats, etc). And the library parses the input and doesn’t rely on fragile regular expressions – instead of randomly breaking, you can catch invalid syntax and handle it appropriately.
Usage
Add the liquid
gem to your project.
The basic operation is two steps:
# Create a template from user-input
template = Liquid::Template.parse("Hi {{ customer.name }}!")
# Render the template with the dynamic data
template.render({"customer": {"name": "Matt"}})
#=> "Hi Matt!"
In practice, there are a few conveniences you’ll want to incorporate into your own Rails view helper.
- Liquid requires the dynamic data hash to have string keys, whereas Rails apps often use symbol keys for hashes. You can call the Rails
deep_stringify_keys
method on a hash to convert them. - Calling
render!
instead ofrender
will raise an exception so that you can fallback to returning the raw user-input. - Liquid provides
strict_variables
andstrict_filters
options that can turn undefined variables or filters into errors. You likely want these both to be true so that users can figure out syntax errors instead of the content silently being blank.
In my project, we added this helper method:
# app/helpers/liquid_helper.rb
module LiquidHelper
def liquid(text, context: {})
template = Liquid::Template.parse(text)
template.render!(context.deep_stringify_keys, {
strict_variables: true,
strict_filters: true
})
rescue Liquid::Error
text.to_s
end
end
And then in any view (or mailer) in your Rails app, you can call the liquid
helper to display dynamic user-generated content.
@campaign = Campaign.create!(subject: "Welcome {{ customer.name }}!")
<%= liquid(@campaign.subject, context: { customer: { name: @customer.name } }) %>
Note: if you are using rich-text via ActionText
, you’ll need to call html_safe
after the Liquid interpolation since the output is raw HTML.
<%= liquid(@campaign.message, context: { customer: { name: @customer.name } }).html_safe %>
This helper gracefully handles error cases by returning the original input.
liquid("Hi {{ missing_value }}", context: {})
#=> "Hi \{\{ missing_value }}"
liquid("Hi {{ foo", context: {})
#=> "Hi \{\{ foo"
You may also want to add convenience methods to your models if you are using them in the Liquid rendering context (instead of building up the context hash every time).
class Customer < ApplicationRecord
belongs_to :organization
def to_liquid
# Expose whatever fields you want to be able to use in liquid templates
{
name: name,
email: email,
company_name: organization.name
}
end
end
liquid("New sign up from {{ customer.company_name}}. Say hi to {{ customer.name }} <{{ customer.email }}>!", context: customer.to_liquid)
#=> New sign up from Arrows. Say hi to Matt <matt@arrows.to>!
Even more advanced battle-tested features
You can register your own filters if you want to provide application-specific functions for users like {{ customer | avatar_url }}
or {{ task.due_date | next_business_day }}
or {{ '#7ab55c' | color_to_rgb }}
.
You can assign resource_limits
to avoid extremely slow interpolations.
These are outside the scope of this tip and you can explore on your own.
References
Liquid docs: Shopify/liquid
Liquid for Programmers: wiki