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:
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.
-
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. ↩