Extending BlogShake with Per-Tag Atom Feeds
Posted on
BlogShake is a minimal static site generator (SSG) built upon the Shake build system. It is extensible, but not in the usual way. Unlike most of SSGs, it has no plugin system. Instead, the users are expected to read and modify BlogShake code. Since it builds upon the solid foundation of Shake and Pandoc, it is actually easy to extend in many possible directions. In this post, we walk through one such change: giving each tag its own Atom feed.
The current code already generates tag archive pages at
/tags/<tag>/ and a global feed at
/feed.atom. We now want to generate
/tags/<tag>/feed.atom to contain the same posts as
the tag’s archive. We already have the supporting pieces in
blog.hs, we just have to bring them together.
The Current
tagArchiveRules
Here is the tag archive rule before the feed support exists:
-- | Writes one archive page per tag.
tagArchiveRules :: RRules ()
tagArchiveRules =
(tagArchivePath </> "*/index.html") @> \target -> do
-- The tag is the path segment after tags/.
let tag = T.pack $ Shake.splitDirectories target !! 2
getPosts
<&> filter (\post -> tag `elem` post.tags)
>>= writeArchive
(T.pack "Posts tagged “" <> tag <> T.pack "”")
Nothing
targettagArchiveRules uses @>,
which crates a rule with one output. Shake matches the given pattern
tags/*/index.html and calls the given action. In the action
we extract the tag from the path, filter all posts by that tag, and use
writeArchive to render and write the archive HTML file.
Evolving
tagArchiveRules
A tag’s feed should contain the same posts as shown on the tag’s
archive page. We could add a separate rule for the
tags/*/feed.atom pattern, but then we would have to filter
the posts twice, wasting computation. Shake has a better fit for this
case: the &%>
rule operator that builds a group of outputs with one action. The &@>
operator in BlogShake wraps it.
We change only the tagArchiveRules:
-- | Writes one archive page and one Atom feed per tag.
tagArchiveRules :: RRules ()
tagArchiveRules =
(map (\f -> tagArchivePath </> "*" </> f) ["index.html", feedFileName])
&@> \[htmlTarget, atomTarget] -> do
-- The tag is the path segment after tags/.
let tag = T.pack $ Shake.splitDirectories htmlTarget !! 2
title = T.pack "Posts tagged “" <> tag <> T.pack "”"
tagPath = tagArchivePath </> T.unpack tag
ctx <- ask
taggedPosts <- filter (\post -> tag `elem` post.tags) <$> getPosts
-- Write the HTML file
writeArchive title (Just tag) htmlTarget taggedPosts
-- Write the Atom file
traverse mkPostEntry taggedPosts
>>= writeFeed
(ctx.config.url <> "/" <> T.pack (feedFileOutputPath tagPath))
(ctx.config.url <> "/" <> T.pack tagPath <> "/")
(ctx.config.title <> " — " <> title)
atomTargetThe rule now lists both the tags/*/index.html and
tags/*/feed.atom patterns, and &@>
registers a single handler action for the group that receives both paths
at once. We extract the tag from htmlTarget, from which we
build tagPath (for example tags/haskell).
Unlike htmlTarget, tagPath does not include
the _site output prefix, so we can use it directly in URLs
in the final feed.
We compute taggedPosts once and reuse it for both HTML
and Atom. writeArchive writes the HTML,
mkPostEntry converts the posts to Atom entries, and
writeFeed writes the feed. We pass Just tag to
writeArchive so the archive template can point its feed
icon at the tag feed. We build the feed URL and feed HTML URL using
config.url
because they need to be absolute. The feed title is the site title plus
the tag archive title.
How This Works
buildTargets only includes the HTML archive for each
tag. Because &@>
registers both patterns as one output group, needing the HTML target
builds the feed target too. We do not have to list the feed targets
separately.
writeArchive already accepts a Maybe tag
and exposes a tag_path value to the
archive.html template. The template uses that value to
choose the feed link, pointing to either the global feed or a tag-wise
feed.
Build and Verify
Run:
./blog.hs build
ls _site/tags/haskell/We should see both feed.atom and
index.html.
Conclusion
Our change is small because it builds upon the existing infrastructure of Shake and BlogShake. We changed one rule to produce two outputs from list of tagged posts, reusing the existing archive and feed helpers. This is how users are supposed to extend BlogShake instead of relying on a plugin system.