Dr. Heuna Kim Mathematics and Computer Science

Dynamic Sidebar or Header Activation based on the Current Page in Hakyll

Situation

Hakyll is a haskell-based static site generator that is used to generate my blog. For the migration I ported Lanyon theme designed for Jekyll to lanyon-hakyll and here I describe one of the problems that I encountered.

The problem is an extension of what is described in this blog: Hakyll, where am I?

The page that you are navigating will be linked to one in your sidebar or your header depending on your layout unless it is one of posts. It is possible to statically link each of such pages manually. But if this list of pages is dynamically generated by loading all pages in some folder (e.g. in the pages folder in lanyon-hakyll), it gets somewhat more complicated in Hakyll for the following reason.

An example based on liquid syntax in ruby for such activation will look like (the excerpt from Lanyon):

{% for node in pages_list %}
    {% if node.title != null %}
          <a class="sidebar-nav-item{% if page.url == node.url %} active{% endif %}" href="{{ node.url | absolute_url }}">{{ node.title }}</a>
    {% endif %}
{% endfor %}

A direct translation of {% if page.url == node.url %} in Hakyll is not possible, because the control flow of Hakyll $if(variable)$ does not evaluate the boolean value of variable but merely checks whether the key variable exists in the current context or not. Check out this tutorial for understanding the control flow of Hakyll templates.

Approaching the Solution

We will dynamically generate this constField having the page title as a key in the context of listField with a key pages_list. First we add a snapshot to avoid a dependency cycle in compiling the pages folder:

 match "pages/*" $ do
    ...
    pandocCompiler
    ...
    >>= saveSnapshot "page-content"

Define the context containing such listField:

sidebarCtx :: Context String -> Context String
sidebarCtx nodeCtx =
    listField "pages_list" nodeCtx (loadAllSnapshots "pages/*" "page-content") `mappend`
    defaultContext

baseNodeCtx :: Context String
baseNodeCtx =
    urlField "node-url" `mappend`
    titleField "title" `mappend`
    baseCtx

baseSidebarCtx = sidebarCtx baseNodeCtx

Add dynamically generated constField with the current page title.

--- This is not enough.
import           System.FilePath               (takeBaseName)

match "pages/*" $ do
    route $ setExtension "html"
    compile $ do
        pageName <- takeBaseName . toFilePath <$> getUnderlying
        let pageCtx = constField pageName "" `mappend`
                      baseNodeCtx
        let activeSidebarCtx = sidebarCtx pageCtx

        pandocCompiler
            >>= saveSnapshot "page-content"
            ...
            >>= loadAndApplyTemplate "templates/default.html" (activeSidebarCtx <> siteCtx)
            >>= relativizeUrls

The translation of the above html layout will be similar to:

<!-- THIS DOES NOT WORK -->
$for(pages_list)$
    $if(title)$
          <a class="sidebar-nav-item$if($title$)$ active$endif$" href="$baseurl$$node-url$">$title$</a>
    $endif$
$endfor$

As you see in the comment, this is not enough because inside of $if(...)$ syntax, you cannot evaluate the key by surrounding them with $.

Solution

We can add functionField for evaluating a key for a given context. The functionField needs a function with a type [String] -> Item String -> Compiler String.

We define the following evalCtxKey function for this purpose:

evalCtxKey :: Context String -> [String] -> Item String -> Compiler String
evalCtxKey context [key] item = (unContext context key [] item) >>= \cf ->
        case cf of
            StringField s -> return s
            _             -> error $ "Internal error: StringField expected"

Just if you need, you can also access the meta data as follows:

getMetadataKey :: [String] -> Item String -> Compiler String
getMetadataKey [key] item = getMetadataField' (itemIdentifier item) key

The functions unContext, getMetadataField', and data itemIdentifier are already defined in Hakyll.

The following is the working version of compiling pages/*:

match "pages/*" $ do
    route $ setExtension "html"
    compile $ do
        pageName <- takeBaseName . toFilePath <$> getUnderlying
        let pageCtx = constField pageName "" `mappend`
                      baseNodeCtx
        let evalCtx = functionField "eval" (evalCtxKey pageCtx)
        let activeSidebarCtx = sidebarCtx (evalCtx <> pageCtx)

        pandocCompiler
            >>= saveSnapshot "page-content"
            ...
            >>= loadAndApplyTemplate "templates/default.html" (activeSidebarCtx <> siteCtx)
            >>= relativizeUrls

and the sidebar layout:

$for(pages_list)$
    $if(title)$
          <a class="sidebar-nav-item$if(eval(title))$ active$endif$" href="$baseurl$$node-url$">$title$</a>
    $endif$
$endfor$

If you want to look at an example code, please check out the codes of lanyon-hakyll.