A downloads manager for Jekyll

On my WordPress blog I ran a plugin called Download Monitor which allowed me to create download ids that could be inserted via short tags. When I updated a download version, any mention of it throughout the site would be updated to show the latest version and link to the most recent download package. I needed something similar on my Jekyll blog to keep things up to date. The following system is geared toward Jekyll but the concept could be adapted to any static blog.

I started by using a script that looped through all of the downloads listed in my WordPress database and generated a CSV file of all of my downloads and their associated metadata. For example:

id,title,file,version,description,info,icon,updated
1,Clippable to Evernote Service,/share/clippable-to-evernote.zip,1.0,"A Snow Leopard System Service [...]",http://brettterpstra.com/code/clippable-to-evernote-service/,/images/serviceicon.jpg,Tue Feb 23 21:55:00 -0600 2010

This file is editable in a plain text editor or in a spreadsheet app like Numbers, and doesn’t require an existing WordPress database to start, just a plain old CSV file with the appropriate data.

Next, I just needed a tag plugin that would let me create a tag like:

{% download 59 %}

The tag would insert a templated “download card” with the appropriate data:

Example download card

The current tag plugin I’m using has the template hardcoded and needs to be updated to handle an actual external template file, but it should give you the idea:

download.rbraw
# Title: Download tag for Jekyll
# Author: Brett Terpstra http://brettterpstra.com
# Description: Create updateable download cards for downloadable projects. Reads from a downloads.csv file in the root folder
#
# Example: {% download id %} to read download [id] from the CSV


module Jekyll
  class DownloadTag < Liquid::Tag
    require 'csv'
    @title = nil
    @file = ''
    @info = ''
    @icon = nil
    @template = 'link'

    def initialize(tag_name, markup, tokens)
      if markup =~ /^(\d+)( .*)?$/
        req_id = $1.strip
        @format = $2.strip.to_i unless $2.nil?
        CSV.read("downloads.csv").each do |line|
          if line[0] == req_id
            id, title, file, version, description, info, icon, updated = line
            @file = file
            @version = version == '' ? '' : %Q{ v#{version}}
            @title = %Q{#{title}#{@version}}
            @icon = icon == '' ? '' : %Q{<img src="#{icon}" width="100" height="100">}
            @updated = updated == '' ? '' : %Q{<p class="dl_updated">Updated #{updated.sub(/[\d:]+ -\d+ (\d+)/,"\\1").strip}.</p>}
            @description = description == '' ? '' : %Q{<p class="dl_description">#{description}</p>#{@updated}}
            @info = info == '' ? '' : %Q{<p class="dl_info"><a href="#{info}" title="More information on #{title}">More info&hellip;</a></p>}
            break
          end
        end
      end
      super
    end
# TODO: Make this read templates
# Example:
#     Dir.chdir(includes_dir) do
#       choices = Dir['**/*'].reject { |x| File.symlink?(x) }
#       if choices.include?(file)
#         source = File.read(file)
#         partial = Liquid::Template.parse(source)
#         context.stack do
#           rtn = rtn + partial.render(context)
#         end
#       else
#         rtn = rtn + "Included file '#{file}' not found in _includes directory"
#       end
#     end
    def render(context)
      output = super
      # TODO: Enable template selection
      if @title
        download = %Q{<div class="download"><h4>#{@title}</h4><p class="dl_icon"><a href="#{@file}" title="Download #{@title}">#{@icon}</a></p><div class="dl_body"><p class="dl_link"><a href="#{@file}">Download #{@title}</a></p>#{@description}#{@info}</div></div>}
      else
        "Error processing input, expected syntax: {% download title filename [url/to/related/post] %}"
      end
    end
  end
end

Liquid::Template.register_tag('download', Jekyll::DownloadTag)

Updating a download’s row in the CSV file with a new download link, version number, update time and/or description change will recreate all of the references to it in the site next time I build it.

I also added a Rake task for searching my downloads and finding the ID for use in the tag:

Rakefile.rbraw
desc "Find a download ID"
task :find_download, :term do |t, args|
  raise "### You haven't created a download csv yet." unless File.exists?('downloads.csv')
  results = CSV.read("downloads.csv").delete_if {|row|
    row[0].strip =~ /^\d+$/ && row[1] + " " + row[4] =~ /.*#{args.term}.*/i ? false : true
  }
  results.sort! { |a,b|
    a[0].to_i <=> b[0].to_i
  }.map! { |res|
    res[0] + ": " + res[1] + " v" + res[3]
  }
  results_menu(results,"download")
  print ("Select download")
  while line = Readline.readline(": ", true)
    if !line || line =~ /^[a-z]/i
      puts "## Canceled"
      Process.exit 0
    end
    line = line.to_i
    if (line > 0 && line <= results.length)
      id = results[line.to_i - 1].match(/^\d+/)[0]
      download_tag = "{% download #{id} %}"
      %x{echo "#{download_tag}\\c"| pbcopy}
      puts %Q{Download tag in clipboard: "#{download_tag}"}
      Process.exit 0
    else
      puts "## Selection out of range"
      Process.exit 0
    end
  end
end

# creates a user menu from a hash or array
def results_menu(res, type = "file")
  counter = 1
  puts
  res.reverse!
  res.each do |match|
    match = match.class == String ? match : match[:path]
    if type == "file"
      display = match.sub(/^.*?\/([^\/]+)$/,"\\1")
      display.gsub!(/^[\d-]+/,'')
      display.gsub!(/\.(md|markdown)$/,'')
      display.gsub!(/-/,' ')
    elsif type == "download"
      display = match.sub(/^\d+:\s*/,'')
    else
      display = match.strip
    end
    printf("%2d ) %s\n", counter, display)
    counter += 1
  end
  puts
end

Now typing rake find_download[nvalt] will show me all downloads matching the search term “nvalt,” offer a menu of matches and put the complete Liquid tag for the selected result in my clipboard:

$ rake find_download[nvalt]

 1 ) OmniFocus Clipper Plugins for Chrome v1.0
 2 ) nvALT 2.2 BETA v2.2b101
 3 ) Marked Watcher Scripts v1.1
 4 ) QuickQuestion v1.1
 5 ) nvALT v2.1

Select download: 2
Download tag in clipboard: "{% download 59 %}"

I also plan to add a Rake task to make adding new downloads and updating existing versions from the command line as simple as possible.

With a little modification, this system could easily be used to generate a “Downloads” page for my site, though I’ve decided that’s really not necessary. I may change my mind in the future, though.

Todo

What I’ve shared here is a functioning system that is currently in use. Before it’s “complete” and ready to share in my plugins repo, there are a few things I’d like to polish:

  • A template system
    • reads a template name from the tag
    • works with multiple templates
  • Rake tasks for adding and editing downloads more easily
  • script for adding downloads via Service or droplet
  • (Possibly) a plugin for generating a Downloads page
Brett Terpstra

Brett is a writer and developer living in Minnesota, USA. You can follow him as ttscoff on Twitter, GitHub, and Mastodon. Keep up with this blog by subscribing in your favorite news reader.

This content is supported by readers like you.

Join the conversation