Customizing Toto to support blog post categories
On this blog I categorize my posts using a few tags, such as https://patshaughnessy.net/tags/paperclip or https://patshaughnessy.net/tags/view-mapper for example. Today I’m going to walk through how I customized the Toto blog engine to display these category pages.
Of course I could have gotten categories for free using a different blog engine such as Jekyll or Nesta, but to be honest when I saw Toto’s code for the first time I fell in love with it: concise, elegant and simple. I just couldn’t wait to try to understand how it works and modify it to do something new. I suppose it really is “the 10 second blog-engine for hackers!”
I’ll go about this in four steps... you can follow the same pattern if you’d like to add something custom to your Toto site:
Step 1: Writing new Riot tests
Toto comes with a fast, effective test suite written using Riot and RR. If you plan to customize Toto, be sure to take advantage of Alexis Sellier’s existing tests to be sure you don’t break anything. Take a look at my last post for some tips on how to get the tests working for Ruby 1.8.7.
I’ll start today by writing a few new Riot tests specifying exactly what behavior I mean by “blog post categories.” I added this code to test/toto_test.rb in my local Toto gem folder:
context "GET a tag page" do setup { @toto.get('/tags/the-wizard-of-oz') } asserts("returns a 200") { topic.status }.equals 200 asserts("body is not empty") { not topic.body.empty? } should("includes only the entries for that tag") { topic.body }.includes_elements("li.entry", 2) should("has access to @tag") { topic.body }.includes_html("#tag" => /The Wizard of Oz/) end
The syntax might be a bit unfamiliar if you’re used to RSpec, but you can probably figure out that the behavior I’m looking for is:
- that GET requests to “/tags/the-wizard-of-oz” return a valid web page that is not empty, and
- that this page contains a list of two articles, just the two that are tagged with “the-wizard-of-oz,” and
- that the human readable version of the tag, “The Wizard of Oz,” appears on this new web page.
I’d like to be able to categorize my posts by adding a new “tag” attribute to the YAML header of each article. So I’ll also have to update a couple of the test articles inside the Toto test suite to make these tests work properly... First I’ll edit test/articles/1990-05-17-the-wonderful-wizard-of-oz.txt and add a tag value like this:
title: The Wonderful Wizard of Oz date: 17/05/1990 tag: The Wizard of Oz _Once upon a time_...
Next, I’ll add the same “tag: The Wizard of Oz” to some other test article, for example test/articles/2001-01-01-two-thousand-and-one.txt.
Now running the tests of course I get failures:
$ rake (in /Users/pat/rails-apps/toto) ...etc... Toto GET a tag page - asserts returns a 200: expected 200, not 404 (on line 115 in ./test/toto_test.rb) + asserts body is not empty - should includes only the entries for that tag: expected <font style='font-size:300%'&rt;toto, we're not... - should has access to @tag: expected <font style='font-size:300%'&rt;toto, we're not in Kansas anymore (404) ...etc... 62 passes, 3 failures, 0 errors in 0.148593 seconds
Here Toto is returning a 404 page not found error since I haven’t implemented anything related to the new category page yet.
Step 2: Write a new ERB template
Another nice thing about Toto is that the article page template and other templates are implemented with ERB by default, which is very familiar and easy to use. I’ll continue now by writing a new ERB file called “tag.rhtml” in tests/templates to render my new category page:
<h1 id="tag">Articles about <%= tag %></h1> <% for entry in archives %> <li class="entry"><%= entry.title %></li> <% end %>
Note I’ve used two HTML details here that correspond to the way I wrote the Riot tests above:
- My <h1> tag uses id=“tag” and contains the tag string <%= tag %>, and:
- Each of the matching articles is listed in an <li> tag with class=“entry.”
Of course, the tag.rhtml code I use on my actual blog site which you’re reading now is a bit more verbose:
<table id="archive-table"> <% if archives.length > 0 %> <% last_month = nil %> <% for entry in archives %> <tr> <td align="right"> <% month = entry.date.split.first %> <% if last_month.nil? || month != last_month %> <%= month %> <%= entry.date.split.last %> <% end %> <% last_month = month %> </td> <td> <a href="<%= entry.path %>"><%= entry.title %></a> </td> </tr> <% end %> <% end %> </table>
There’s just a bit of logic around displaying the month and year text, and also a couple of CSS cues for my blog’s stylesheet. But to get the tests to pass you just need the barebones tag.rhtml file I showed above.
Let’s try the tests again:
$ rake (in /Users/pat/rails-apps/toto) ...etc... Toto GET a tag page - asserts returns a 200: expected 200, not 404 (on line 115 in ./test/toto_test.rb) + asserts body is not empty - should includes only the entries for that tag: expected <font style='font-size:300%'&rt;toto, we're not... - should has access to @tag: expected <font style='font-size:300%'&rt;toto, we're not in Kansas anymore (404) ...etc... 62 passes, 3 failures, 0 errors in 0.148593 seconds
It’s still failing because I haven’t changed Toto’s routing method to find the new ERB file yet... let’s do that next.
Step 3: Add a new route
Toto implements routing using the Toto::Site.go method. Here’s what that looks like:
def go route, env = {}, type = :html route << self./ if route.empty? type, path = type =~ /html|xml|json/ ? type.to_sym : :html, route.join('/') context = lambda do |data, page| Context.new(data, @config, path, env).render(page, type) end body, status = if Context.new.respond_to?(:"to_#{type}") if route.first =~ /\d{4}/ case route.size when 1..3 context[archives(route * '-'), :archives] when 4 context[article(route), :article] else http 400 end elsif respond_to?(path) context[send(path, type), path.to_sym] elsif (repo = @config[:github][:repos].grep(/#{path}/).first) && !@config[:github][:user].empty? context[Repo.new(repo, @config), :repo] else context[{}, path.to_sym] end else http 400 end rescue Errno::ENOENT => e return :body => http(404).first, :type => :html, :status => 404 else return :body => body || "", :type => type, :status => status || 200 end
Let’s take a few minutes to understand this method... this bit of code is really interesting. It plays the same role that routes.rb does in a Rails application. When Toto gets an HTTP request, it converts the path string into an array of strings and passes that into the “go” method as the first parameter: “route.” For example, if a user requests https://patshaughnessy.net/2011/1/23/4-tips-for-how-to-customize-a-toto-blog-site, then route will be set to:
["2011", "1", "23", "4-tips-for-how-to-customize-a-toto-blog-site"]
The code above is basically a switch statement that decides which RHTML template file to use based on this route array. So for viewing a single blog post, route will contain 4 strings and this highlighted code will be called:
...etc...case route.size when 1..3 context[archives(route * '-'), :archives]
when 4 context[article(route), :article]else http 400 end...etc...
...returning this value:
context[article(route), :article]
Toto next evaluates this by calling the “context” lambda defined near the top of the “go” method:
context = lambda do |data, page| Context.new(data, @config, path, env).render(page, type) end
This eventually runs ERB on the selected RHTML file and returns the result as the response body sent to the user. The “:article” symbol indicates Toto should use the tempates/pages/article.rhtml template. The “Context” class represents the context inside of which the ERB template is evaluated, containing both the data (the article object in this case) and the configuration values specified in config.ru.
So to get Toto to use my new tag.rhtml template, I need to return this from the “go” routing method:
context[data, :tag]
... where “data” contains a hash of the values I used in the tag.rhtml file. For example:
context[{ :tag => 'The Wizard of Oz', :archives => [ ...array of tagged articles... ] }, :tag]
Here’s how to do it - I’ll repeat the entire “go” method again:
def go route, env = {}, type = :html route << self./ if route.empty? type, path = type =~ /html|xml|json/ ? type.to_sym : :html, route.join('/') context = lambda do |data, page| Context.new(data, @config, path, env).render(page, type) end body, status = if Context.new.respond_to?(:"to_#{type}") if route.first =~ /\d{4}/ case route.size when 1..3 context[archives(route * '-'), :archives] when 4 context[article(route), :article] else http 400 end elsif route.first == 'tags' && route.size == 2 if (data = archives('', route[1])).nil? http 404 else context[data, :tag] end elsif respond_to?(path) context[send(path, type), path.to_sym] elsif (repo = @config[:github][:repos].grep(/#{path}/).first) && !@config[:github][:user].empty? context[Repo.new(repo, @config), :repo] else context[{}, path.to_sym] end else http 400 end rescue Errno::ENOENT => e return :body => http(404).first, :type => :html, :status => 404 else return :body => body || "", :type => type, :status => status || 200 end
You can see I match on routes.first == ‘tags’ and that there’s exactly one more level to the path; for example /tags/the-wizard-of-oz. Then I call the existing archives method, and pass in the tag from the URL as a parameter:
data = archives('', route[1])
Finally if this is not nil, I return the desired context value:
context[data, :tag]
I’ll get to the archives method next, but in the meantime let’s run the tests again:
$ rake (in /Users/pat/rails-apps/toto) ...etc... Toto GET a tag page - asserts returns a 200: expected 200, not 404 (on line 115 in ./test/toto_test.rb) + asserts body is not empty - should includes only the entries for that tag: expected <font style='font-size:300%'&rt;toto, we're not... - should has access to @tag: expected <font style='font-size:300%'&rt;toto, we're not in Kansas anymore (404) ...etc... 62 passes, 3 failures, 0 errors in 0.148593 seconds
Still failing - but at least I know I haven’t broken any of the other existing routes since all of the other 62 tests still pass!
Step 4: Implement your new behavior
Finally, we need to actually filter the list of articles on the given tag somehow. I’ll do this by modifying the existing Toto::Site.archives method. Here’s the original version of the method:
def archives filter = "" entries = ! self.articles.empty?? self.articles.select do |a| filter !~ /^\d{4}/ || File.basename(a) =~ /^#{filter}/ end.reverse.map do |article| Article.new article, @config end : []return :archives => Archives.new(entries, @config) end
This code is called to generate the index page for a Toto blog (e.g. https://patshaughnessy.net), and also the year/month archives pages by providing a value for the filter parameter. It returns a single Archives object which contains an array of Article objects.
And here’s my version:
def archives filter = "", tag = nil entries = ! self.articles.empty?? self.articles.select do |a| filter !~ /^\d{4}/ || File.basename(a) =~ /^#{filter}/ end.reverse.map do |article| Article.new article, @config end : []if tag.nil? { :archives => Archives.new(entries, @config) } else tagged = entries.select do |article| article_tag = article[:tag] article_tag && article_tag.slugize == tag end { :tag => tagged.first[:tag], :archives => tagged } if tagged.size > 0 endend
My changes are highlighted; I started by adding a new parameter called “tag,” which will contain the tag string we are looking for. If this is not nil, then before returning the entire list of articles I select only the articles that have article[:tag].slugize equal to the tag from the URL. "slugize" means to convert from the human readable version to the short URL version. Finally I return a hash containing two values: the actual (non-slug) tag string, and also the list of matching articles. If no articles have the requested tag, then I return nil.
Re-running the tests again:
$ rake (in /Users/pat/rails-apps/toto) ...etc... Toto GET a tag page + asserts returns a 200 is equal to 200 + asserts body is not empty + should includes only the entries for that tag + should has access to @tag ...etc... 65 passes, 0 failures, 0 errors in 0.158935 seconds
... they pass! Now I’m ready to edit my real blog application, add the new tag.rhtml template to it, and try out my category page.
In the next few days I’ll post these changes on github, possibly in a pull request so others can use the feature also. But for me the real fun is in understanding how Toto works, and how easy it is to add your own custom pages to it.