How to Add Backlinks in Nanoc Static Site Generator

Since I’ve added backlinks to the bottom of posts today, I figured I might as well share the code.

I’m using static site generator nanoc. It has a preprocessing section in the compilation step where you can add new items and modify items that are read from disk further. I got the basis for this code from Denis Defreyne, creator of nanoc. (Check out his code.)

Denis’s code is actually very well suited to publish a set of notes with [[wiki link]] style links. If you want to create a static site wiki, definitely check out his approach.

This is a regular blog, though, so I modified the code a bit to match absolute URLs and relative URLs that involve the /posts path component. It also deals with the fact that some source files are called title-of-the-post.md, and others are title-of-the-post/index.md. I used to put them into folders to group them with images. But years later I wasn’t happy with that convention because now all files were called “index.md”. So nowadays I’m mixing both and only put images into a subdirectory of the same name as the post.

A lot of tweaks later, the code is surprisingly long, but works pretty well:

preprocess do
  def find_backlinks
    backlinks_to = {}
    skipped_notes = ["/posts/overview"]
    domain = config[:base_url]
    # Start with the domain, or be inside a (/...) Markdown inline link or a [...]: reference style link
    link_regex = /(?<=#{domain}\/|\(\/|\: \/)(.*?)(?:="|\)|\s|$)/

    def remove_trailing_slash(str)
      if str[-1..] == "/"
        str[...-1]
      else
        str
      end
    end

    def trim_index(str)
      if i = (str =~ %r{/index.*})
        str[...i]
      else
        str
      end
    end

    @items.find_all('/posts/**/*').each do |origin|
      next if origin.binary?
      next if skipped_notes.include?(origin.identifier.without_exts)
      # transform links into a format that looks like identifier.without_ext
      linked_paths = origin.raw_content
                       .scan(link_regex)
                       .map { |matches| matches[0] }
                       .map { "/" + remove_trailing_slash(_1) }
                       .map { trim_index(_1) }
      linked_paths.each do |linked_path|
        backlinks_to[linked_path] ||= []
        backlinks_to[linked_path] << origin.identifier
      end
    end

    @items.find_all('/posts/**/*').each do |target|
      next if target.binary?
      key = trim_index(target.identifier.without_exts)
      next if skipped_notes.include?(key)
      target[:backlinks] = backlinks_to.fetch(key, [])
    end
  end

  # call this before adding e.g. the tag index to limit the amount of @items
  find_backlinks
end

Then to render backlinks below the post, this is part of the layout template:

<% if @item[:backlinks].any? %>
  <aside id="backlinks">
    <h2>Links to this article</h2>
    <ul class="entries">
      <% @item[:backlinks].map { |id| @items[id] }.sort_by { _1[:title] }.each do |note| %>
        <li class="entry">
          <h3 class="title"><%= link_to(note[:title], note) %></h3>
          <%= Post::excerpt_for(note, no_readmore: true) %>
        </li>
    <% end %>
    </ul>
  </aside>
<% end %>

The Post::excerpt_for function is an old helper I wrote in 2013 or so. It takes the first whole paragraph of a post instead of X characters, cutting off mid-sentence. The no_readmore option turns of adding “Continue reading…” links below the excerpt text. You can use the built-in #excerptize text helper for that instead.