In Hakyll, how can I generate a tags page?
Asked Answered
F

2

15

I'm trying to do something like what's described in this tutorial, i.e., add tags to my Hakyll blog, but instead of generating a page for every tag, just have one page that lists all tags and their posts. So given a Post1 tagged Tag1, and a Post2 tagged Tag1, Tag2, and a Post3 tagged Tag2, my tags.html would look like this:

 Tag1: 
  - Post1
  - Post2
 Tag2: 
  - Post2
  - Post3

But I'm a Haskell beginner, and I don't fully understand all of Hakyll's monadic contexts. Here's what I have so far:

create ["tags.html"] $ do
    route idRoute
    tags <- buildTags "posts/*" (fromCapture "tags.html")
    compile $
        makeItem ""
            >>= applyTemplate tagListTemplate defaultContext
            >>= applyTemplate defaultTemplate defaultContext
            >>= relativizeUrls
            >>= cleanIndexUrls

The problem is, I don't really know what Tags are, in the context of my blog. I can't seem to print them out for debugging. (I tried adding print tags, but it doesn't work.) So I'm having a really hard time thinking about how to proceed with this.

The complete file is here on GitHub.

Any help is much appreciated.

Update: I'm still not much closer to figuring this out. Here's what I'm trying now:

create ["tags.html"] $ do
        route idRoute
        tags <- buildTags "posts/*" (fromCapture "tags.html#")
        let tagList = tagsMap tags
        compile $ do
            makeItem ""
              >>= applyTemplate tagListTemplate (defaultCtxWithTags tags)

along with:

-- Add tags to default context, for tag listing
defaultCtxWithTags :: Tags -> Context String
defaultCtxWithTags tags = listField "tags" defaultContext (return (tagsMap tags)) `mappend` defaultContext

The full code, as it currently stands, is up here.

Any help with this would be much appreciated. I'm aware of all the documentation, but I can't seem to translate that into working code.

Frosted answered 14/10, 2018 at 17:22 Comment(7)
See the documentation on what Tags are. You will need to use one of the functions that take a Tags argument and produce a Compiler or better Context, that you can use instead of (or together with) the defaultContext.Decasyllabic
That documentation doesn't really make sense to me, as a beginner. What function should I use to take the tags that I generated, and make a context from it that contains a list of all tags, and all their associated posts?Frosted
It's a normal algebraic data type. You can use tagsMap tags to get a list of tuples, each with a tag name and a list of page identifiers having that tag. Then read the Template and Context documentation for how to build a listField from that, which you should be able to use to render a list of tag names.Decasyllabic
(Rendering the (titles etc of) associated posts themselves will be more advanced, since the tag list will only contain post identifiers. You won't easily get the metadata of that page to render it, that'll require additional effort).Decasyllabic
What will that involve, exactly? Is there a function for getting the title of a page, given its identifier?Frosted
Yes, getMetadata will do that (when called in a Compiler monad or the top-level Rule monad). But first try to get the list of tag names working, and post an update to your question if you did.Decasyllabic
I just posted an update. I'm getting closer, but I don't think I know how to make a listField from tagsMap tags.Frosted
C
13

I have modified your site.hs to create a rudimentary tags list page which I believe has the structure required: a list of tags, each of which contains a list of posts with that tag.

Here's a summary of each of the things that I had to do to get it to work:

{-# LANGUAGE ViewPatterns #-}

Not strictly necessary, but a nice language extension which I use once. I thought I'd use/mention it since you mentioned that you're a beginner to Haskell, and it's nice to know about.

tags <- buildTags "posts/*" (fromCapture "tags/*.html")

There are two changes needed to this line, compared to the buildTags in your initial site.hs. One is that it should probably moved out of the individual match clauses into the top level Rules monad, so that we can create individual tag pages if required. The other is that the capture was similarly changed from "tags.html#" to "tags/*.html". This is important because Hakyll wants every Item to have a unique Identifier, and not every tags page is the same.

Having the individual tag pages with unique identifiers may not be strictly necessary, but simplifies the rest of the setup since a lot of the Hakyll machinery assumes they exist. In particular, the Tags: line in the individual post descriptions was not previously being rendered correctly either.

For the same reason, it's a good idea to actually make these individual tag pages routable: without this stanza in the top-level Rules monad, the tags on each post won't render correctly with the default tagsField that you use, since they can't figure out how to link to an individual tag page:

tagsRules tags $ \tag pat -> do
    route idRoute
    compile $ do
        posts <- recentFirst =<< loadAll pat
        let postCtx = postCtxWithTags tags
            postsField = listField "posts" postCtx (pure posts)
            titleField = constField "title" ("Posts tagged \""++tag++"\"")
            indexCtx = postsField <> titleField <> defaultContext
        makeItem "" >>= applyTemplate postListTemplate indexCtx
                    >>= applyTemplate defaultTemplate defaultContext
                    >>= relativizeUrls
                    >>= cleanIndexUrls

Alright, that's the preliminaries. Now onto the main attraction:

defaultCtxWithTags tags = listField "tags" tagsCtx getAllTags         `mappend`
                          defaultContext

Alright, the important thing added here is some tags field. It will contain one item for each thing returned by getAllTags, and the fields on each item will be given by tagsCtx.

  where getAllTags :: Compiler [Item (String, [Identifier])]
        getAllTags = pure . map mkItem $ tagsMap tags
          where mkItem :: (String, [Identifier]) -> Item (String, [Identifier])
                mkItem x@(t, _) = Item (tagsMakeId tags t) x

What's getAllTags doing? Well, it starts with tagsMap tags, just like your example. But Hakyll wants the result to be an Item, so we have to wrap it up using mkItem. What's in an Item other than the body? Just an Identifier, and the Tags object happens to contain a field that tells us how to get this! So mkItem just uses tagsMakeId to get an identifier and wraps up the given body with that identifier.

What about tagsCtx?

        tagsCtx :: Context (String, [Identifier])
        tagsCtx = listFieldWith "posts" postsCtx getPosts             `mappend`
                  metadataField                                       `mappend`
                  urlField "url"                                      `mappend`
                  pathField "path"                                    `mappend`
                  titleField "title"                                  `mappend`
                  missingField

Everything starting with metadataField is just the usual stuff we expect to get from defaultContext; we can't use defaultContext here since it wants to add a bodyField, but the body of this Item isn't a string (but instead a much more useful for us Haskell structure representing a tag). The interesting bit of this is line which adds the posts field, which should look a bit familiar. The big difference is that it uses listFieldWith instead of listField, which basically means that getPosts gets an extra argument, which is the body of the Item that this field is on. In this case, that's the tag record from tagsMap.

          where getPosts :: Item (String, [Identifier])
                         -> Compiler [Item String]
                getPosts (itemBody -> (_, is)) = mapM load is

getPosts mostly just uses the load function to get ahold of the Item for each post given its Identifier---it's a lot like the loadAll you do to get all the posts on the index page, but it just gives you one post. The weird-looking pattern-match on the left is ViewPatterns in action: it's basically saying that for this pattern to match, the pattern on the right of the -> (i.e. (_, is)) should match the result of applying the function on the left (i.e. itemBody) to the argument.

                postsCtx :: Context String
                postsCtx = postCtxWithTags tags

postsCtx is very simple: just the same postCtxWithTags used everywhere else we render a post.

That's everything necessary to get a Context with everything that you want; all that's left is to actually make a template to render it!

tagListTemplateRaw :: Html
tagListTemplateRaw =
  ul $ do
    "$for(tags)$"
    li ! A.class_ "" $ do
      a ! href "$url$" $ "$title$"
      ul $ do
        "$for(posts)$"
        li ! A.class_ "" $ do
          a ! href "$url$" $ "$title$"
        "$endfor$"
    "$endfor$"

This is just a very simple template that renders nested lists; you could of course do various things to make it fancier/nicer-looking.

I have made a PR to your repo so that you can see these changes in context here.

Cystolith answered 22/11, 2018 at 0:47 Comment(1)
Thanks so much for this very thorough answer! Besides being helpful for me, I think it'll go a long way toward helping others that are also looking to do this in Hakyll, too.Frosted
D
1

Here is what we've done to achieve this behaviour on our webpage:

Kowainik webpage tags building

And the example of the tag page:

https://kowainik.github.io/tags/haskell

You can ask any questions about the code :)

Dry answered 1/11, 2018 at 9:10 Comment(4)
If I'm not mistaken, it looks like your website has one page for each tag. I can achieve that just fine, but what I'm having trouble with is making one page that lists all tags, and for each tag lists all its associated posts.Frosted
@Jono We actually have a page that lists all tags (this is all posts page), but without showing associated posts with that. But probably the trick with grabbing all tags from all posts could be useful for you if I understand your problem correctly.Dry
I can manage to print out a list of all tags, and I can make tag pages which each list all the associated posts for that tag, but I can't make a page that lists tags along with their associated posts. I have a feeling it involves creating a tagsListContext, a listField of tags, and listFields inside each of those. But I don't understand how a field is necessarily associated with an Item, and how they interact. I just wish I could find a working example of a tags page that lists all tags, and for each one, lists all the associated posts.Frosted
Could you edit your answer to include a working code block that lists tags, then within each, lists their associated posts? It would be something that begins create ["tags.html"] $ do, I'm guessing. If so, I'd happily give you the bounty.Frosted

© 2022 - 2024 — McMap. All rights reserved.