Easy blogging with nanoc
The static site compiler nanoc is an excellent tool for blogging. You have to a bit “techy” to use this tool for blogging because it does involve a lot of coding (in Ruby) and there is no cms or user interface to easily create pages and edit them.
The newest version of nanoc allows you to create commands using the Cri module, and it is excellent for creating blog posts, that have a semantic structure. In other words, you don’t want to do that by hand every time.
The idea
The idea is to have a directory structure that looks like this:
blog
|
|---2012
| |
| |---12
| | |
| | |--a-blog-post-from-december.html
| |
| |---10
| | |
| | |--another-blog-post.html
| | |
... etc
Prerequisites
I am assuming a style of blog, where all posts are places under /blog/{year}/{month}/name-of-post.html
. I am also assuming that you have some experience with nanoc.
For the following examples to work you need the stringex
gem and the highline
gem installed. Otherwise do it now (installing without the additional documentation):
gem install --no-ri --no-rdoc stringex
gem install --no-ri --no-rdoc highline
You should of course also have nanoc installed. Do this with:
gem install --no-ri --no-rdoc nanoc
Remember that you can keep all these dependecies handled by the bundler
gem via a Gemfile. Go look it up on the internet.
Install the Blogging and Tagging helpers from nanoc. In /lib/helpers.rb (create this file if you don’t already have it), add this at the top:
#...
include Nanoc::Helpers::Blogging
include Nanoc::Helpers::Tagging
#...
Creating a blog post
The following script is a nanoc command, that creates an intial blog post scaffolding. We save a lot of information in the yaml header, as a blog post is a semantic structure (when was it created, which tags etc.). This post will be saved under /blog/{year}/{month}/name.html
.
Put this file in /commands/new-blog.rb
summary 'creates a new blog post'
description <<desc
This command creates a new blog post under content/blog/{year}/{month}/example.html.
You can additionally pass in the description, the tags and the creation date.
desc
usage 'new-blog name [options]'
option :d, :description, 'description for this blog post (ex. "This is a description")', :argument => :optional
option :t, :tags, 'tags for this blog post (ex. "these,are,tags")', :argument => :optional
option :c, :created_at, 'creation date for this blog post (ex. "2013-01-03 10:24")', :argument => :optional
flag :h, :help, 'show help for this command' do |value, cmd|
puts cmd.help
exit 0
end
run do |opts, args, cmd|
# requirements
require 'stringex'
require 'highline'
# load up HighLine
line = HighLine.new
# get the name and description parameter or the default
name = args[0] || "New blog post"
description = opts[:description] || "This is the description"
# convert the tags string to and array of trimmed strings
tags = opts[:tags].split(",").map(&:strip) rescue []
# convert the created_at parameter to a Time object or use now
timestamp = DateTime.parse(opts[:created_at]).to_time rescue Time.now
# make the directory for the new blog post
dir = "content/blog/#{timestamp.year}/#{'%02d' % timestamp.month}"
FileUtils.mkdir_p dir
# make the full file name
filename = "#{dir}/#{name.to_url}.html"
# check if the file exists, and ask the user what to do in that case
if File.exist?(filename) && line.ask("#{filename} already exists. Want to overwrite? (y/n)", ['y','n']) == 'n'
# user pressed 'n', abort!
puts "Blog post creation aborted!"
exit 1
end
# write the scaffolding
puts "Creating new post: #{filename}"
open(filename, 'w') do |post|
post.puts "---"
post.puts "title: #{name}"
post.puts "created_at: #{timestamp}"
post.puts "description: #{description}"
post.puts "kind: article"
post.puts "layout: blog"
post.puts "tags: #{tags.inspect}"
post.puts "---\n\n"
end
end
You can pass several options to this script to customize the blog post:
-n, –name : The name of the blog post
-d, –description : The description of the blog post
-c, –created_at : The creation date of the blog post
For more info on this, try running the help command: nanoc help new-blog
.
Sorting blog posts in year - month folders
To be able to keep the structure of the blog posts, i created a command that sorts them under their respective subfolders according to the :created_at
metadata element. This script can also dump the whole blog structure to a yaml file for easy references later, e.g. you want to find a blog post, but forgot in which folder you put it.
usage 'sort-blogs [options]'
summary 'sorts the blogs in a year/month dir structure'
description 'moves blog posts to their respective folders, e.g. content/blog/{year}/{month}/example.html'
flag :h, :help, 'show help for this command' do |value, cmd|
puts cmd.help
exit 0
end
option :p, :prune, 'removes empty folders', :argument => :optional
option :y, :yaml, 'create yaml overview of blog posts', :argument => :optional
run do |opts, args, cmd|
blog_posts = {}
site = Nanoc::Site.new(".")
items = site.items.select{|i| i[:kind] == 'article'}
for item in items
source = item[:content_filename]
return unless item[:created_at]
year = item[:created_at].year
month = item[:created_at].month
month_name = Date::MONTHNAMES[month]
day = item[:created_at].day
dir = "content/blog/#{year}/#{'%02d' % month}"
FileUtils.mkdir_p(dir)
dest = "#{dir}/#{File.basename(source)}"
unless source == dest
puts "#{source}\t\t->\t\t#{dest}"
FileUtils.mv(item[:content_filename], dest)
end
if opts[:prune]
old_dir = File.dirname(source)
FileUtils.rm_rf(old_dir) if Dir.glob("#{old_dir}/*").empty?
end
if opts[:yaml]
blog_posts[year] ||= {}
blog_posts[year][month_name] ||= {}
blog_posts[year][month_name][day] = item[:title]
end
end
if opts[:yaml]
open('blogs.yaml', 'w') do |post|
post.puts blog_posts.to_yaml
end
end
end
If you pass the -p
option to this script it will prune empty folders in the blog structure. This is handy of you changed the :created_at
attribute, and the script moves a single blog post to another folder, thus leaving an empty folder. If you pass the -y
parameter, it will dump a blogs.yaml
file in your project root with an overview of all blog posts.
Generate index pages for years, months and tags
You can do this in the preprocess
directive in your Rules
file. This basically will create a page in every year subfolder and month subfolder that functions as an landing page with a list of the children of that folder and subfolders. It also creates landing pages for all tags.
In lib/helpers
, add these three functions:
def get_date(item)
attribute_to_time(item[:created_at] || item[:mtime])
end
def get_timeline
dates = []
for item in sorted_articles
date = get_date(item)
dates << Date.new(date.year, date.month)
end
dates.uniq
end
def all_tags
tags = []
sorted_articles.each do |item|
next if item[:tags].nil?
item[:tags].each { |tag| tags << tag) }
end
tags.uniq
end
In Rules
, put this at the top:
preprocess do
for tag in all_tags
@items << Nanoc::Item.new("", {:title => tag.capitalize, :tag => tag, :layout => "tag", :extension => 'html'}, "/blog/tags/#{tag.to_url}/")
end
for date in get_timeline
@items << Nanoc::Item.new("", {:title => "Blog posts from #{date.year}", :menu_title => date.year, :year => date.year, :layout => "timeline", :extension => "html"}, "/blog/#{date.year}/")
@items << Nanoc::Item.new("", {:title => "Blog posts from #{Date::MONTHNAMES[date.month.to_i]} #{date.year}", :menu_title => Date::MONTHNAMES[date.month.to_i], :year => date.year, :month => date.month, :layout => "timeline", :extension => "html"}, "/blog/#{date.year}/#{'%02d' % date.month}/")
end
end
This code adds items at compile time just before the rest of the site os compiled. You basically just add extra items to the @items array, which in turn then also will be compiled. We add one page for each year that has blog posts, and one page for each month. We also add pages for all the tags
Here are the layout files for these pages:
/layouts/timeline.html
:
<ul>
<% for item in articles_with_year_and_month(@item[:year], @item[:month]) %>
<%= render 'listitem', :item => item %>
<% end %>
</ul>
/layouts/tag.html
:
<ul>
<% for item in articles_with_tag(@item[:tag]) %>
<%= render 'listitem', :item => item %>
<% end %>
</ul>
And the partial listitem.html
:
<li>
<a href="<%= item.path %>">
<h3><%= @item[:title] %></h3>
<p><em><%= friendly_date(@item[:created_at]) %></em></p>
<p><%= @item[:description] %></p>
</a>
</li>
These views uses a few helper methods to get the right posts (drop this in /lib/helpers.rb
):
def articles_with_year_and_month(year, month)
articles = []
unless month.nil?
articles = sorted_articles.select{|i| year == get_date(i).year && month == get_date(i).month}
else
articles = articles.select{|i| year == get_date(i).year}
end
articles
end
def articles_with_tag(tag)
sorted_articles.select{|a| a[:tags].include?(tag) rescue false }
end