Streamlining rules in nanoc

Published
2014-05-21
Tagged

Nanoc is an excellent static site generator. Probably my favourite feature is the rule-based document generation system. This system makes it incredibly easy to “pipe” documents from a given folder or point in your file hierarchy to a specific url on your site, with support for as many filters as you want between the document and the unfinished product.

Rules come in three flavours, but we’re interested in two for this article: compile rules and routing rules. Compile rules tell a file what it should look like when it appears on the internet, while routing rules tell a file where it should end up relative to your site’s root:

If I seem to be beating this point into the ground, it’s because this is the key to understanding workflows in nanoc.

Here’s an example compile rule:

1
compile "/blog/*/" do
2
  filter :images
3
  filter :redcarpet
4
  layout "blog"
5
end

Filters get run on the file’s text body, outputting formatted text1 which is then either passed to the next filter or output to the nanoc item’s compiled_content property. Layouts allow us to re-use basic html wrappers.

Here’s an example routing rule:

1
route "/blog/*/" do
2
  item.identifier.gsub(%r<^/blog>,"") + "index.html"
3
end

We’ve run a bit of ruby on the item’s identifier (basically a path to the file in your content tree) that removes the prefix “/blog”, and adds “index.html” to the end of it. This would turn a blog post located at, for example, /blog/2014-05-20/foo/, into /2014-05-20/foo/index.html.2

Routing rules and compile rules often work together. You’ll often have a folder of text items that all need to be compiled and routed the same way. When building my site, I found myself continually doing the following:

1
compile "/foo/*/" do
2
  # compile logic...
3
end
4
5
route "/foo/*/" do
6
  # routing logic...
7
end

For every pattern I’m matching (here: “/foo/*/”) I have a specific set of routing and compile logic. However, it feels like I’m repeating myself between the compile and route methods. I’d much prefer to write:

1
path "/foo/*" do
2
  compile do
3
    # compile logic...
4
  end
5
6
  route do
7
    # route logic...
8
  end
9
end

This sort of domain-specific language (DSL) is often done in ruby using a combination of classes, methods, and calling instance_exec. For example:

1
# Stores all of the special DSL methods
2
class GreeterDSL
3
  def initialize(g)
4
    @greeting = g
5
  end
6
7
  def greet(name)
8
    puts "#{@greeting}, #{name}!"
9
  end
10
end
11
12
# Helper method gives us access to the DSL itself
13
def greeter(g, &blck)
14
  GreeterDSL.new(g).instance_exec(&blck)
15
end
16
17
# And in the code we write...
18
greeter("Good morning") do
19
  greet "Bob" # => "Good morning, Bob!"
20
  greet "Mary" # => "Good morning, Mary!"
21
  # etc.
22
end

If you squint, this contrived example may look like the code fragments I’ve shown you above: that’s because it is similar. Nanoc parses most of its Rules file through the CompilerDSL class. Since we’re just making helper methods, we’ll be making calls to the CompilerDSL class quite a bit. That means we need to pass our wrapper class:

  • The path we want to use, and
  • The DSL object whose methods we want to call

Our class’ constructor will therefore look like this:

1
class RuleDSL
2
  def initialize(path, dsl)
3
    @path = path
4
    @dsl = dsl
5
  end
6
end

We also want two methods: one that calls the DSL’s compile method, and one that calls its route method:

1
class RuleDSL
2
  def compile &blck
3
    dsl.compile(@path, &blck)
4
  end
5
6
  def layout &blck
7
    dsl.layout(@path, &blck)
8
  end
9
end

The last thing we need to do is inject a new method into Nanoc’s CompilerDSL class:

1
class Nanoc::CompilerDSL
2
  def path(path, &blck)
3
    RuleDSL.new(path, self).instance_exec(&blck)
4
  end
5
end

And we’re done! We can now call path to our heart’s delight within Nanoc’s Rules file, and benefit from cleaner code and more concise rules.

Extra for experts

Of course, now we’ve found that we can inject arbitrary code into the CompilerDSL class, the sky’s the limit. Consider this simple method that could clean up your Rules file if you use markdown a lot and like “pretty” URLs for your entries and pages:

1
class Nanoc::CompilerDSL
2
  def markdown_rule *paths
3
    paths.each do |p|
4
      compile(p){ filter :redcarpet; layout "default" }
5
      route(p){ item.identifier + "/index.html" }
6
    end
7
  end
8
end

This could greatly simplify your site:

1
# Before
2
compile("/blog/*/") do
3
  filter :redcarpet
4
  layout "default"
5
end
6
7
route("/blog/*/") do
8
  item.identifier + "/index.html"
9
end
10
11
compile("/about/") do
12
  filter :redcarpet
13
  layout "default"
14
end
15
16
route("/about/") do
17
  item.identifier + "/index.html"
18
end
19
20
compile("/projects/*") do
21
  filter :redcarpet
22
  layout "default"
23
end
24
25
route("/projects/*") do
26
  item.identifier + "/index.html"
27
end
28
29
compile("/galleries/public/*") do
30
  filter :redcarpet
31
  layout "default"
32
end
33
34
route("/galleries/public/*") do
35
  item.identifier + "/index.html"
36
end
37
38
# After
39
markdown_rule "/blog/*/", "/about/", "/projects/*", "/galleries/public/*"

Or maybe you’re tired of making compile blocks that always filter through haml but use slightly different layouts each time:

1
class RuleDSL
2
  def compile_with_haml(layout)
3
    dsl.compile(@path) do
4
      filter :haml
5
      layout layout
6
    end
7
  end
8
end

But where should this code go?

There’s a couple of places you could stick code like this. The most obvious place would be right at the top of the Rules file, but that gets a bit ugly. Perhaps a better place would be in a separate file in the /lib folder of your document structure. These files will be required before the Rule file is run, so you get access to everything in your /lib folder for free.


  1. Filters can also run on binary files, and can also output binary files, allowing you to (for example) convert from xml or JSON to SVG or something else weird and strange. There’s a lot of fun stuff to poke around in under the hood of nanoc. 

  2. Astute readers will note that the file doesn’t actually live at /blog/2014-05-20/foo/, but would instead be /blog/2014-05-20/foo.md or something similar. Nanoc creates identifiers by stripping the file extension from the file and adding a trailing slash, for its own reasons. This also allows you to associate metadata with an image by storing the image at foo.png and its accompanying metadata at foo.yaml. Which is a nice trick.