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.