Boring Rails

Tip: Dynamic user content in Rails with Liquid tags

:fire: Tiny Tips

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 of render will raise an exception so that you can fallback to returning the raw user-input.
  • Liquid provides strict_variables and strict_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

If you like these tips, you'll love my Twitter account. All killer, no filler.