Curly

Free your views!

Curly is a template language that completely separates structure and logic. Instead of interspersing your HTML with snippets of Ruby, all logic is moved to a presenter class, with only simple placeholders in the HTML.

While the basic concepts are very similar to Mustache or Handlebars, Curly is different in some key ways:

  • Instead of the template controlling the variable scope and looping through data, all logic is left to the presenter object. This means that untrusted templates can safely be executed, making Curly a possible alternative to languages like Liquid.
  • Instead of implementing its own template resolution mechanism, Curly hooks directly into Rails, leveraging the existing resolvers.
  • Because of the way it integrates with Rails, it is very easy to use partial Curly templates to split out logic from a presenter. With Mustache, at least, when integrating with Rails, it is common to return Hash objects from view object methods that are in turn used by the template.

Installing

Installing Curly is as simple as running gem install curly-templates. If you're using Bundler to manage your dependencies, add this to your Gemfile

gem 'curly-templates'

How to use Curly

In order to use Curly for a view or partial, use the suffix .curly instead of .erb, e.g. app/views/posts/_comment.html.curly. Curly will look for a corresponding presenter class named Posts::CommentPresenter. By convention, these are placed in app/presenters/, so in this case the presenter would reside in app/presenters/posts/comment_presenter.rb. Note that presenters for partials are not prepended with an underscore.

Add some HTML to the partial template along with some Curly variables:

<!-- app/views/posts/_comment.html.curly -->
<div class="comment">
  <p>
    {{author_link}} posted {{time_ago}} ago.
  </p>

  {{body}}
</div>

The presenter will be responsible for filling in the variables. Add the necessary Ruby code to the presenter:

# app/presenters/posts/comment_presenter.rb
class Posts::CommentPresenter < Curly::Presenter
  presents :comment

  def body
    BlueCloth.new(@comment.body).to_html
  end

  def author_link
    link_to(@comment.author.name, @comment.author, rel: "author")
  end

  def time_ago
    time_ago_in_words(@comment.created_at)
  end
end

The partial can now be rendered like any other, e.g. by calling

render 'comment', comment: comment
render comment
render collection: post.comments

Examples

Here is a simple Curly template -- it will be looked up by Rails automatically.

<!-- app/views/posts/show.html.curly -->
<h1>{{title}}<h1>
<p class="author">{{author}}</p>
<p>{{description}}</p>

{{comment_form}}

<div class="comments">
  {{comments}}
</div>

When rendering the template, a presenter is automatically instantiated with the variables assigned in the controller or the render call. The presenter declares the variables it expects with presents, which takes a list of variables names.

# app/presenters/posts/show_presenter.rb
class Posts::ShowPresenter < Curly::Presenter
  presents :post

  def title
    @post.title
  end

  def author
    link_to(@post.author.name, @post.author, rel: "author")
  end

  def description
    Markdown.new(@post.description).to_html.html_safe
  end

  def comments
    render 'comment', collection: @post.comments
  end

  def comment_form
    if @post.comments_allowed?
      render 'comment_form', post: @post
    else
      (:p, "Comments are disabled for this post")
    end
  end
end

Caching

Caching is handled at two levels in Curly – statically and dynamically. Static caching concerns changes to your code and templates introduced by deploys. If you do not wish to clear your entire cache every time you deploy, you need a way to indicate that some view, helper, or other piece of logic has changed.

Dynamic caching concerns changes that happen on the fly, usually made by your users in the running system. You wish to cache a view or a partial and have it expire whenever some data is updated – usually whenever a specific record is changed.

Dynamic Caching

Because of the way logic is contained in presenters, caching entire views or partials by the data they present becomes exceedingly straightforward. Simply define a #cache_key method that returns a non-nil object, and the return value will be used to cache the template.

Whereas in ERB you would include the cache call in the template itself:

<% cache([@post, signed_in?]) do %>
  ...
<% end %>

In Curly you would instead declare it in the presenter:

class Posts::ShowPresenter < Curly::Presenter
  presents :post

  def cache_key
    [@post, signed_in?]
  end
end

Likewise, you can add a #cache_duration method if you wish to automatically expire the fragment cache:

class Posts::ShowPresenter < Curly::Presenter
  ...

  def cache_duration
    30.minutes
  end
end

Static Caching

Static caching will only be enabled for presenters that define a non-nil #cache_key method (see "Dynamic Caching.")

In order to make a deploy expire the cache for a specific view, set the version of the view to something new, usually by incrementing by one:

class Posts::ShowPresenter < Curly::Presenter
  version 3

  def cache_key
    # Some objects
  end
end

This will change the cache keys for all instances of that view, effectively expiring the old cache entries.

This works well for views, or for partials that are rendered in views that themselves are not cached. If the partial is nested within a view that is cached, however, the outer cache will not be expired. The solution is to register that the inner partial is a dependency of the outer one such that Curly can automatically deduce that the outer partial cache should be expired:

class Posts::ShowPresenter < Curly::Presenter
  version 3
  depends_on 'posts/comment'

  def cache_key
    # Some objects
  end
end

class Posts::CommentPresenter < Curly::Presenter
  version 4
  depends_on 'posts/comment'

  def cache_key
    # Some objects
  end
end

Now, if the version of Posts::CommentPresenter is bumped, the cache keys for both presenters would change. You can register any number of view paths with depends_on.

Thanks

Thanks to Zendesk for sponsoring the work on Curly.

Copyright (c) 2013 Daniel Schierbeck (@dasch), Zendesk Inc.

Licensed under the Apache License Version 2.0.