- 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:
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.
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. ↩︎
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 atfoo.png
and its accompanying metadata atfoo.yaml
. Which is a nice trick. ↩︎