Easy blogging with nanoc

WARNING: This article is still under construction, some parts are still missing. The implementation is not complete.

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

Comments

comments powered by Disqus