How to build flexible components in Jekyll using Nokogiri and Liquid blocks.
I wanted re-usable ‘components’ on my Jekyll sites which I could use to render common chunks of content. These components would need to accept parameters that would change the layout or styling of the rendered HTML and also accept any HTML that I wanted rendered on the page. After trying a bunch of options I found that Liquid block plugins combined with Nokogiri was the best solution.
No need to worry about writing markup or classes on my pages, just set a bunch of attributes and params and let the template do the rest:
# Call the component something like this:
<my-custom-component color=red>
<heading>Big Red Text</heading>
<content>
Any kind of <strong>markup here</strong>.
</content>
</my-custom-component>
# Have the markup look something like this:
<div>
<h1 style="color: red">Big Red Text</h1>
<div>
Any kind of <strong>markup here</strong>.
</div>
</div>
The following approach uses a Liquid Tag block type Jekyll plugin letting you pass in any pipeline separated params you might want, and any custom tags into the block. The tags are parsed by Nokogiri allowing for bug free HTML rendering with access to attributes if you need them.
# _plugins/foo/foo.rb
require "jekyll"
require "nokogiri"
module Foo
class Tag < Liquid::Block
def initialize(tag_name, input, parse_context)
@params = parse_params(input.strip)
super
end
def parse_params(input)
result = {}
input.split('|').each do | item |
key = item.split('=').first
value = item.split('=').last
result[key] = value.delete_prefix('"').delete_suffix('"')
end
return result
end
def render(context)
@text = super
elements = Nokogiri::HTML::DocumentFragment.parse(@text)
template = "#{File.dirname(__FILE__)}/template.liquid"
liquid = Liquid::Template.parse(File.read(template))
variables = {
"params" => @params,
"heading" => elements.search('heading').text,
"content" => elements.search('content').to_html,
}
return liquid.render(variables)
end
Liquid::Template.register_tag "foo", self
end
end
Your template files can stay nice and neat because all the logic and defaults are handled by Ruby in the plugin.
# _plugins/foo/template.html
<h1 class="{{ params.color }}">
{{ heading }}
</h1>
{{ content }}
Call the block like this in your templates:
# page.html
{% foo color=red|size=large %}
<heading>Big Red Text</heading>
<content>
<p>Any markup here</p>
</content>
{% endfoo %}
Using Nokogiri to parse the content has an added benefit of providing access to attributes on any of the tags you pass into the content of a block. Here’s an example of a method I used to render a list of links.
def get_links(elements)
items = []
elements.search('links').each do | element |
items << {
"label" => element.text,
"href" => element.attributes['href'].value,
}
end
return items
end
The links were passed into the block like so:
{% foo %}
<links href="/">Homepage</links>
<links href="/about">About us</links>
<links href="/contact">Contact</links>
{% endfoo %}
Jekyll’s built in includes system was the
first approach I tried and it does work but has some drawbacks. Your template
would live in _includes
and the parameters would be passed in using the
standard method:
# _includes/my-custom-component.html
<div>
<h1 style="color: {{ include.color }}">
{{ include.heading }}
</h1>
<div>
{{ include.body }}
</div>
</div>
# page.html
{% include my-custom-component.html
color="red"
heading="Big Red Text"
%}
No problem, we can use the Liquid capture variable to pass in whatever we want:
{% capture body %}
<p>I'm the body text</p>
{% endcapture %}
It’d be useful to have some defaults and we could use Liquid’s ‘default’ method to pull this off. If you only have a few params you can add them inline to your template but I find it’s easier to organise if you have them all listed at the top of the file:
# Inline your defaults if you only have a few
<h1>{% raw%}{{ include.heading | default: "Default Heading" }}</h1>
# List all defaults at the top if you have many
# Note that we renamed the variables here so you'll need to
# use 'heading' instead of 'include.heading' in the template.
{% if include.heading %}
{% assign heading = include.heading %}
{% else %}
{% assign heading = "Default Heading" %}
{% endif %}
{% if include.color %}
{% assign color = include.color %}
{% else %}
{% assign color = "red" %}
{% endif %}
<div>
<h1>{{ heading }}</h1>
...