Rubyoshka - Composable HTML templating for Ruby
INSTALL | TUTORIAL | EXAMPLES | REFERENCE
What is Rubyoshka
Rubyoshka is an HTML templating engine for Ruby that offers the following features:
- HTML templating using plain Ruby syntax
- Minimal boilerplate
- Mix logic and tags freely
- Use global and local contexts to pass values to reusable components
- Automatic HTML escaping
- Composable nested components
- Template caching from fragments to whole templates
Note Rubyoshka is a new library and as such may be missing features and contain bugs. Also, its API may change unexpectedly. Your issue reports and code contributions are most welcome!
With Rubyoshka you can structure your templates like a Russian doll, each component containing any number of nested components, in a somewhat similar fashion to React. The name Rubyoshka is a nod to Matryoshka, the Russian nesting doll.
Installing Rubyoshka
Using bundler:
gem 'rubyoshka'
Or manually:
$ gem install rubyoshka
Getting started
To use Rubyoshka in your code just require it:
require 'rubyoshka'
Alternatively, you can import it using Modulation:
Rubyoshka = import('rubyoshka')
To create a template use Rubyoshka.new
or the global method Kernel#H
:
# can also use Rubyoshka.new
html = H {
div { p 'hello' }
}
Rendering a template
To render a Rubyoshka template use the #render
method:
H { span 'best span' }.render #=> "<span>best span</span>"
The render method accepts an arbitrary context variable:
html = H {
h1 context[:title]
}
html.render(title: 'My title') #=> "<h1>My title</h1>"
All about tags
Tags are added using unqualified method calls, and are nested using blocks:
H {
html {
head {
title 'page title'
}
body {
article {
h1 'article title'
}
}
}
}
Tag methods accept a string argument, a block, or no argument at all:
H { p 'hello' }.render #=> "<p>hello</p>"
H { p { span '1'; span '2' } }.render #=> "<p><span>1</span><span>2</span></p>"
H { hr() }.render #=> "<hr/>"
Tag methods also accept tag attributes, given as a hash:
H { img src: '/my.gif' }.render #=> "<img src="/my.gif"/>
H { p "foobar", class: 'important' }.render #=> "<p class=\"important\">foobar</p>"
Logic in templates
Since Rubyoshka templates are just a bunch of Ruby, you can easily write your view logic right in the template:
def (user)
H {
if user
span "Hello, #{user.name}!"
else
span "Hello, guest!"
end
}
end
Local context
When writing logic and referring to application values in you templates, there
are some ground rules to obey. Since the template code is evaluated using
#instance_eval
that means that you will not be able to directly use instance
variables or do unqualified method calls (calls to self
).
In order to facilitate exposing values to your template logic, Rubyoshka
provides an API for setting a local context. The local context is simply a set
of values that are accessible for a given block of code, and to any nested
blocks within it. The local context is primarily set using the #with
method:
H {
with(name: 'world') {
div {
span "Hello, #{name}"
}
}
}
The local context can alternatively be set by passing hash values to #H
:
H(name: 'world') {
div { span "Hello, #{name}" }
}
A local context can also be set for a component (see the next section) simply by passing arguments to the component call:
Greeting = H { span "Hello, #{name}" }
H {
div {
Greeting(name: 'world')
}
}
Tip: accessing self
and instance variables from a template
In order to be able to access the object in the context of which the template is defined or any of its methods, you can pass it in the local context:
class User
...
def greeting_template
H(user: self) {
...
span "Hello, #{user.name}"
span "your email: #{user.email}"
}
end
end
Instance variables can be passed to the template in a similar fashion:
H(name: @name) { span "Hello, #{name}" }
Global context
In addition to the local context, Rubyoshka also provides a way to set a global context, accessible from anywhere in the template, and also in sub-components used in the template.
The global context is a simple hash that can be accessed from within the
template with the #context
method:
greeting = H { span "Hello, #{context[:name]}" }
The global context can be set upon rendering the template:
greeting.render(name: 'world')
Templates as components
Rubyoshka makes it easy to compose multiple separate templates into a whole HTML
document. Each template can be defined as a self-contained component that can be
reused inside other components. Components can be defined as either a Rubyoshka
instance (using #H
), a proc
that returns a Rubyoshka instance, or using
Rubyoshka.component
:
# Simple component relying on global/local context
Title = H { h1 title }
# Proc component that returns a template
# Notice how the lambda expression takes keyword arguments
Item = ->(id:, text:, checked:) {
H {
li {
input name: id, type: 'checkbox', checked: checked
label text, for: id
}
}
}
# Components using Rubyoshka.component (or H.component) are a bit more compact.
# Any parameters are passed as arguments to the block.
NavBar = Rubyoshka.component do |links|
div {
links.each { |l| a l[:title], href: l[:url] }
}
end
def render_items(items, links)
html = H {
Title()
NavBar(links)
ul {
items.each { |id, attributes|
Item id: id, text: attributes[:text], checked: attributes[:active]
}
}
}.render(title: 'Hello from components')
end
Note that a component is invoked as a method, which means that if no arguments are passed, you must add an empty pair of parens, as shown in the example above.
In addition to using components defined as constants, you can also use
non-constant components by invoking the #emit
method:
greeting = H { span "Hello, world" }
H {
div {
emit greeting
}
}
Layout templates
Rubyoshka templates can also be used to implement layout templates by using
#emit_yield
:
layout = H {
html5 {
head { ... }
body {
header { ... }
body {
emit_yield
}
footer { ... }
}
}
}
To use the layout, supply a block when calling #render
:
layout.render {
h1 'foo'
p 'bar'
}
# you can also pass a template as the block
inner = H {
h1 'foo'
p 'bar'
}
layout.render(&inner)
Fragment caching
Any part of a Rubyoshka template can be cached - a fragment, a component, or a
whole template. It is up to you, the user, to determine which parts of the
template to cache. By default, a call to #cache
creates a cache entry based on
the source location of the cached block:
Head = H {
cache {
head {
title 'My app'
style "@import '/app.css';"
}
}
}
However, if your template references local or global variables, you'll want to
take those into account when caching. This is done by passing any variables used
in the template to #cache
in order to create separate cache entries for each
discrete value or combination of values:
Greeting = H {
cache(name) {
div {
span "Hello, #{name}"
}
}
}
names = %w{tommy dolly world}
App = H {
names.each { |n| Greeting(name: n) }
}
In the above example a separate cache entry will be created for each name. The use of caching in components is especially beneficial since components may be reused in multiple different templates in your app.
Changing the cache store
Rubyoshka ships with a naïve in-memory cache store built-in. You can use
another cache store by overriding the Rubyoshka.cache
method (see API
reference).
Wrapping arbitrary HTML with a component
Components can also be used to wrap arbitrary HTML with addional markup. This is
done by implementing the component as a proc
that takes a block:
Header = ->(&inner_html) {
header {
h1 'This is a title'
emit inner_html
}
}
Greeting = H { span "Hello, #{name}" }
H { Header { Greeting(name: 'world') }.render #=> "<header><h1>This is a title</h1><span>Hello, world</span></header>"
Some interesting use cases
Rubyoshka opens up all kinds of new possibilities when it comes to putting together pieces of HTML. Feel free to explore the API!
Routing in the view
The following example demonstrates a router component implemented as a pure function. The router simply returns the correct component for the given path:
Router = ->(path) {
case path
when '/'
PostIndex()
when /^posts\/(.+)$/
Post(get_post($1))
end
}
Blog = H {
html {
head {
title: 'My blog'
}
body {
Topbar()
Sidebar()
div id: 'content' { Router(context[:path]) }
}
}
}
A general purpose router
A more flexible, reusable approach could be achieved by implementing a higher-order routing component, in a similar fashion to React Router:
Route = ->(path, &block) {
match = path.is_a?(Regexp) ?
context[:path] =~ path : context[:path] == /^#{path}/
emit block if match
}
Blog = H {
html {
head {
title: 'My blog'
}
body {
Topbar()
Sidebar()
div id: 'content' {
Route '/' { PostIndex() }
Route /^posts\/(.+)$/ { Post(get_post($1)) }
}
}
}
}
A higher-order list component
Here's another demonstration of a higher-order component, a list component that
takes an item component as an argument. The List
component can be reused for
rendering any kind of unordered list, and with any kind of item component:
List = ->(items, item_component) {
H {
ul {
items.each { |item|
with(item: item) {
li { emit item_component }
}
}
}
}
}
TodoItem = H {
span item.text, class: item.completed ? 'completed' : 'pending'
}
def todo_list(items)
H {
div { List(items, TodoItem) }
}
end
API Reference
Rubyoshka#initialize(**context, &block)
a.k.a. Kernel#H
context
: local context hashblock
: template block
Initializes a new Rubyoshka instance. This method takes a block of template
code, and an optional local context in the form of a hash.
The Kernel#H
method serves as a shortcut for creating Rubyoshka instances.
Rubyoshka#render(**context)
context
: global context hash
Renders the template with an optional global context hash.
Methods accessible inside template blocks
#<tag/component>(*args, **props, &block)
args
: tag arguments. For an HTML tag Rubyoshka expects a singleString
argument containing the inner text of the tag.props
: hash of tag attributesblock
: inner HTML block
Adds a tag or component to the current template. If the method name starts with an upper-case letter, it is considered a component.
If a text argument is given for a tag, it will be escaped.
#cache(*vary, &block)
vary
: variables used in cached block. The given values will be used to create a separate cache entry.block
: inner HTML block
Caches the markup in the given block, storing it in the Rubyoshka cache store. If a cache entry for the given block is found, it will be used instead of invoking the block. If one or more variables given, those will be used to create a separate cache entry.
#context
Accesses the global context.
#emit(object)
a.k.a. #e(object)
object
:Proc
,Rubyoshka
instance orString
Adds the given object to the current template. If a String
is given, it is
rendered verbatim, i.e. without escaping.
html5(&block)
block
: inner HTML block
Adds an HTML5 doctype
tag, followed by an html
tag with the given block.
#text(data)
data
- text to add
Adds text without wrapping it in a tag. The text will be escaped.
#with(**context, &block)
context
: local context hashblock
: HTML block
Sets a local context for use inside the given block. The previous local context will be restored upon exiting the given block.
Rubyoshka.cache
Returns the cache store. A cache store should implement two methods - #[]
and
#[]=
. Here's an example implementing a Redis-based cache store:
class RedisTemplateCache
def initialize(conn, prefix)
@conn = conn
@prefix = prefix
end
def [](key)
@conn.get("#{prefix}:#{key}")
end
def []=(key, value)
@conn.set("#{prefix}:#{key}", value)
end
end
TEMPLATE_CACHE = RedisTemplaceCache.new(redis_conn, "templates:cache")
def Rubyoshka.cache
TEMPLATE_CACHE
end