Streamlining rules in nanoc

Published
21 June 2014
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.

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:

compile "/blog/*/" do
  filter :images
  filter :redcarpet
  layout "blog"
end

Filters get run on the file's text body, outputting formatted text[1] 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:

route "/blog/*/" do
  item.identifier.gsub(%r<^/blog>,"") + "index.html"
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:

compile "/foo/*/" do
  # compile logic...
end

route "/foo/*/" do
  # routing logic...
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:

path "/foo/*" do
  compile do
    # compile logic...
  end

  route do
    # route logic...
  end
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:

# Stores all of the special DSL methods
class GreeterDSL
  def initialize(g)
    @greeting = g
  end

  def greet(name)
    puts "#{@greeting}, #{name}!"
  end
end

# Helper method gives us access to the DSL itself
def greeter(g, &blck)
  GreeterDSL.new(g).instance_exec(&blck)
end

# And in the code we write...
greeter("Good morning") do
  greet "Bob" # => "Good morning, Bob!"
  greet "Mary" # => "Good morning, Mary!"
  # etc.
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:

class RuleDSL
  def initialize(path, dsl)
    @path = path
    @dsl = dsl
  end
end

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

class RuleDSL
  def compile &blck
    dsl.compile(@path, &blck)
  end

  def layout &blck
    dsl.layout(@path, &blck)
  end
end

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

class Nanoc::CompilerDSL
  def path(path, &blck)
    RuleDSL.new(path, self).instance_exec(&blck)
  end
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:

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

This could greatly simplify your site:

# Before
compile("/blog/*/") do
  filter :redcarpet
  layout "default"
end

route("/blog/*/") do
  item.identifier + "/index.html"
end

compile("/about/") do
  filter :redcarpet
  layout "default"
end

route("/about/") do
  item.identifier + "/index.html"
end

compile("/projects/*") do
  filter :redcarpet
  layout "default"
end

route("/projects/*") do
  item.identifier + "/index.html"
end

compile("/galleries/public/*") do
  filter :redcarpet
  layout "default"
end

route("/galleries/public/*") do
  item.identifier + "/index.html"
end

# After
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:

class RuleDSL
  def compile_with_haml(layout)
    dsl.compile(@path) do
      filter :haml
      layout layout
    end
  end
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. ↩︎