Hiccdown
Hiccdown is a simple Ruby gem that parses arrays and turns them into HTML strings.
In Rails, it solves a major problem nobody talks about.
The name is a variation on the popular Clojure package Hiccup. Hiccdown introduces the same basic functionality in Ruby (with some extensions, see below).
The problem
If you’re used to writing embedded Ruby (those pesky .erb
files), you may not realize how bad it is.
Consider this template:
<ul>
<% [1, 2, 3].each do |i| %>
<li><%= i %></li>
<% end %>
</ul>
This is gross. Embedded Ruby makes you mix your template and your logic. Rails is big on separation of concerns, and the above example is the opposite of that. It’s “programming in strings”, as a former colleague of mine calls it.
The fundamental mistake is that of forcing the language in charge of assembling and rendering the template – in this case, Ruby – into the template itself. Ruby should be in control; ‘above’ the template, as it were. Instead, it’s demoted to living inside its own creation, resurfacing only through strange interpolative outgrowths.
This problem is well known in the Clojure world. Logic should be taken care of before rendering, not during.
Hiccdown takes a datastructure representing your template – which you’re free to build up programmatically in any way you like, using the full power of Ruby (map
, filter
, reduce
etc) – and then turns that datastructure into HTML at the end. All of this still happens on the server, so you still get all the benefits of pre-processing.
Compare the above erb
syntax with this simple but functionally equivalent Hiccdown syntax:
[:ul, [1, 2, 3].map { |i| [:li, i] }]
Benefits
Here are some of the benefits of Hiccdown:
- Clean separation of logic and presentation – never write HTML again
- More concise, Ruby-native syntax
- Easier programmatic manipulation of content – data is easier to traverse and manipulate than HTML strings
- Simplified post-processing without additional parsing libraries
- Reduced risk of HTML-injection vulnerabilities
- Better composability and reusability of components
- Easier to generate dynamic structures
- Enhanced static analysis capabilities
Once you understand these benefits, you’ll realize, for example, that Rails having both helper methods and view partials has always been a code smell – see below.
Installation
In your Gemfile:
gem 'hiccdown'
Then $ bundle
.
Usage in Ruby
The original Hiccup explanation applies:
The first [item] of the [array] is used as the element name. The second [item] can optionally be a map, in which case it is used to supply the element's attributes. Every other [item] is considered part of the [element]'s body.
# plain
Hiccdown.to_html [:h1, 'hello world']
# => '<h1>hello world</h1>'
# nested elements
Hiccdown.to_html [:div, [:h1, 'hello world']]
# => '<div><h1>hello world</h1></div>'
# nested siblings
Hiccdown.to_html [:div, [:h1, 'hello world'], [:h2, 'hello again']]
# => '<div><h1>hello world</h1><h2>hello again</h2></div>'
# attributes
Hiccdown.to_html [:h1, {class: 'heading big'}, 'hello world']
# => '<h1 class="heading big">hello world</h1>'
# children as arrays
Hiccdown.to_html [:ul, [[:li, 'first'], [:li, 'second']]]
# => '<ul><li>first</li><li>second</li></ul>'
#
# This is equivalent to writing:
Hiccdown.to_html [:ul, [:li, 'first'], [:li, 'second']]
# So why use it? So you can use methods that return arrays inside your hiccdown
# structure without having to use the splat operator every time:
Hiccdown.to_html [:ul, ['first', 'second'].map { |i| [:li, i] }]
# => '<ul><li>first</li><li>second</li></ul>'
Components
Methods that return Hiccdown act as components:
def component
[:h1, 'hello world']
end
Programmatically nest Hiccdown for rich components:
def component text, *children
[:div, text, *children]
end
component('hello world', [:p, 'hello america'], [:p, 'hello europe'])
# => [:div, 'hello world', [:p, 'hello america'], [:p, 'hello europe']]
Usage in Rails
Include the following module in your ApplicationHelper
:
module ApplicationHelper
include Hiccdown::ViewHelpers
end
View replacement
Hiccdown replaces view files. It intercepts render
to point to helper methods instead.
For instance, picture a ProductsController
with an index
and a show
action:
class ProductsController < ApplicationController
def index
@products = Product.all
end
def show
@product = Product.find(params[:id])
end
end
Hiccdown then calls the index
and show
methods on the ProductsHelper
, turns the return value into HTML, and renders it in the browser, inside the application layout, just as you would expect for an erb
template. Helper methods thus become Hiccdown components:
module ProductsHelper
def index
[:ul, @products.map { |p| [:li, p.title] }]
# => Renders '<ul><li>…</li>…</ul>'
end
def show
[:div
[:h1, @product.title]
[:span, @product.description]]
# => Renders '<div><h1>…</h1><span>…</span></div>'
end
end
You can also render Hiccdown directly in your controller:
class FooController < ApplicationController
def bar
render hiccdown: [:h1, 'hello world!']
end
end
Hiccdown can be used inside .erb templates, but that’s discouraged:
<!-- bar.html.erb -->
<%= Hiccdown.to_html([:h1, @text]).html_safe %>
(Be careful with html_safe
.)
Usage with additional helper methods
Since Hiccdown code lives inside helpers anyway, simply use additional helper methods in your Hiccdown code:
module ProductsHelper
def index
[:ul, @products.map { |p| product(p) }] # calls `product` method below
end
def show
[:div
[:h1, @product.title]
[:span, @product.description]]
end
# This would traditionally live in a _product.html.erb partial
def product p
[:li, p.title]
end
end
As you can see, Hiccdown eliminates the need for view partials, as well. Again, that both partials and helper methods exist in Rails has always been a code smell – it’s a consequence of the wider problem that Rails does not properly separate logic and rendering. This fudge leads to situations where, for instance, you’re not sure if you should make a partial that calls helper methods or create a helper method that uses content_tag
s.
Using existing Rails helpers
You can continue using Rails’s built-in helper methods such as link_to
:
module ProductsHelper
def product p
[:li,
link_to(p.title, p)] # functionally equivalent to
end # [:a, { href: url_for(p) }, p.title]
end
Built-in helper methods can process Hiccdown returned by blocks:
module ProductsHelper
def product p
[:li,
link_to(p) do
[:h2, p.title]
end]
end
end
However, rather than use built-in helpers that render HTML, you are encouraged to just use Hiccdown replacements whenever possible. In many cases, nesting Hiccdown structures lets you avoid blocks altogether:
[:a, { href: url_for(p) }, # instead of link_to
[:h2, p.title]]
link_to
, button_to
, content_tag
, and all other built-in helper methods for rendering markup should support Hiccdown blocks. Form helpers support them as well:
module ProductsHelper
def form p
form_with(model: p) do |f|
[:div,
f.text_field(:title),
f.text_area(:description),
[:button, { type: :submit }, 'Submit']]
end
end
end
Scoping
Computations should generally precede the building of a Hiccdown structure itself. Remember, these are all just helper methods, so as long as your method returns Hiccdown, any valid Ruby code works:
def product p
total_sold = product.sales.map(&:total).reduce(&:+)
[:li, p.title,
[:strong, 'Sold: ', total_sold]] # total_sold can also be a separate method altogether
end
But sometimes, you want to perform computations within a Hiccdown structure. Hiccdown ships with a simple method called scope
:
def product p
[:li, p.title,
scope do
total_sold = product.sales.map(&:total).reduce(&:+)
[:strong, 'Sold: ', total_sold]
end]
end
scope
accepts arbitrary arguments for easy variable setup:
scope(1, 2, 3) do |a, b, c|
# Instead of
# a = 1
# b = 2
# c = 3
end
Outside of Rails, scope
is available on the Hiccdown module: Hiccdown.scope
Gradual rollout
You don’t need to replace your views all at once. When there’s no helper method corresponding to a controller action, Rails will render the erb
template as it normally would. Once you’ve migrated a template, simply delete it.
In addition, you can still call render
in your helpers. So, when a view renders a partial, you can continue to render
it in your helper until you’ve migrated the partial itself. For example:
# ProductsHelper
def index
[:div,
[:h1, 'Products'],
render(@products)]
end
<!-- app/views/products/_product.html.erb -->
<h1><%= product.title %></h1>
Then, migrate the _product.html.erb
partial into a helper method called product
in the same module and update the index
method accordingly:
def index
[:div,
[:h1, 'Products'],
# Invoke `product` instead of `render`
@products.map { |p| product(p) }]
end
# This replaces _product.html.erb
def product p
[:h1, p.title]
end
Lastly, delete _product.html.erb
.
Usage with turbo streams
Where previously you might render a turbo stream like this:
turbo_stream.update(@product, partial: 'products/product', locals: { product: @product })
You now pass Hiccdown by invoking the helper method that replaces the partial:
turbo_stream.update(@product, hiccdown: product(@product)
Or pass a Hiccdown structure directly:
turbo_stream.update(@product, hiccdown: [:h1, @product.title])
Since turbo streams are just glorified HTML templates, you can also just add the following method to your ApplicationHelper
:
def ts action, target, *children
[:'turbo-stream', {action: action, target: target},
[:template, *children]]
end
# Easy creation of a hiccdown turbo stream
ts(:update, dom_id(@product), product(@product))
HTML escape
Hiccdown escapes HTML characters in attribute values and primitive children. You can override this behavior by passing false
as the second parameter:
Hiccdown.to_html([:h1, '<script>alert("pwned");</script>'], false)
Hiccdown does not escape strings marked as html_safe
. This can be useful when rendering HTML entities:
[:p,
'foo',
' · '.html_safe,
'bar']
# => Browser renders this as 'foo · bar'
Hiccup extensions
For convenience, Hiccdown extends Hiccup in three ways:
-
Deeply nested attribute hashes result in hyphenated attribute keys. This is useful for constructing data attributes. For example:
Hiccdown.to_html([:div, { data: { foo: { bar: 'baz' }, fuzz: 'buzz' } }]) # => '<div data-foo-bar="baz" data-fuzz="buzz"></div>'
-
Array attribute values are concatenated with a space (after each being cast to a string and escaped).
nil
and empty strings are ignored. This is useful for programmatically building class attributes:Hiccdown.to_html([:div, { class: ['foo', :bar, nil, '', 1] }]) # => '<div class="foo bar 1"></div>'
Of course, these first two extensions can be mixed:
Hiccdown.to_html([:div, { data: { foo: ['bar', :baz] } }])
# => '<div data-foo="bar baz"></div>'
-
To get top-level siblings, ie elements without a parent, wrap them in an array. The elements can be arrays and/or strings and will simply be concatenated:
Hiccdown.to_html([[:div, 'foo'], [:div, 'bar']]) # => '<div>foo</div><div>bar</div>' Hiccdown.to_html(['foo', [:div, 'bar']]) # => 'foo<div>bar</div>'
Todos
- Could the application layout live in ApplicationHelper#layout?
- How to use this with turbo streams?
- Is there a way to teach user-built helpers how to process Hiccdown? Or maybe intercepting
capture
already took care of this? - Bug: redirects result in two additional requests, the first of which is a turbo-stream request that renders nothing, thus (presumably) prompting the browser to make another request for the same resource. This? https://stackoverflow.com/a/74071278
- Use frame layout for turbo frame requests? https://discuss.rubyonrails.org/t/the-right-way-to-override-render-method/84765/2
- Some Reagent-like way to make things reactive using proc as first element? And then the server keeps track of which procs have been rendered, which items have changed, and re-renders that part of the template in a turbo stream?
- Bug: When an empty block is passed to render, it results in an empty tag '<>'