hakyll/web/tutorials/03-arrows.markdown
2012-08-21 13:15:15 +02:00

9.3 KiB

title: Arrow Magic: Metadata Dependent Page Generation author: Florian Hars

Supporting a "published: false" attribute on pages

Many content management systems or blog platforms support some kind of workflow that display articles differently or not at all depending on which state the article is in, for example whether it has a "published" attribute or not. Hakyll has no built-in support for anything like this, but since its compilers are just arrows, it is easy to implement arbitrary metadata dependent behaviour for rendering pages.

Let's start by adding support for a "published" attributes to the simpleblog example. We want to consider a blog post published if it has a published metadata element that does not have the value false. A function to test for this is simple:

isPublished :: Page a -> Bool
isPublished p =
  let published = getField "published" p in
  published /= "" && published /= "false"

The next step is to write a function that tags a page with its published status, which can be either unpublished or published, using the standard Either datatype and then transform this function into a Compiler. The latter can be done with the standard arr function from Control.Arrow, which lifts a function into an arrow:

isPagePublished :: Compiler (Page a) (Either (Page a) (Page a))
isPagePublished = arr (\p -> if isPublished p then Right p else Left p)

For the next processing steps we now need a compiler that takes an Either (Page a) (Page a) instead of the usual Page a as an input. But the former can be built up from the latter using some standard combinators from the Control.Arrow library. The simplest one is |||, which takes two compilers (arrows) with the same output type and returns a new compiler that takes an Either of the input types of the Compilers as an input. Maybe we just want to render our unpublished posts with a big warning that they are provisional, so we just want to render the unpublished Left pages with another template than the published Right pages:

  match "posts/*" $ do
    route   $ setExtension ".html"
    compile $ pageCompiler
        >>> isPagePublished
        >>> (applyTemplateCompiler "templates/embargo-post.html"
             ||| applyTemplateCompiler "templates/post.html")
        >>> applyTemplateCompiler "templates/default.html"
        >>> relativizeUrlsCompiler

With the conditional rendering in place, the next step is to hide the unpublished posts from the homepage and the list of posts. Both lists are generated from the results of a requireAllA call. The last argument of requireAllA is a Compiler, and requireAllA passes a pair consisting of the currently rendered page and a list of all the required pages. All we have to do to suppress the pages is to write a Compiler that takes such a pair as input, leaves the first element of the pair unchanged and filters out all the unpublished pages from list in the second element of the pair and then pass the output from this compiler to the existing compilers handling the list generation for the index and posts pages.

Again, we can use a function from Control.Arrow to build this compiler from simpler ones, in this case it is ***, which combines two arrows to one arrow from pairs to pairs. For our purposes, we combine the identity arrow, which leaves its input unchanged, and an ordinary filter on a list lifted into the compiler arrow:

filterPublished :: Compiler (Page a, [Page b]) (Page a, [Page b])
filterPublished = id *** arr (filter isPublished)

All that remains to do is to chain this compiler in front of the existing compilers passed to requireAllA in the code for posts.html

    >>> requireAllA "posts/*" (filterPublished >>> addPostList)

and for index.html:

    >>> requireAllA "posts/*"
          (filterPublished
           >>> (id *** arr (take 3 . reverse . sortByBaseName))
           >>> addPostList)

You may have noticed that the code for the index page uses the same id *** something construct to extract some elements from the list of all posts.

Don't generate unpublished pages at all

The above code will treat unpublished posts differently and hide them from all lists of posts, but they will still be generated, and someone who knows their URLs will still be able to access them. That may be what you need, but sometimes you might want to suppress them completely. The simplest way to do so is to leave the rendering pipeline for "posts/*" unchanged and just add the isPagePublished compiler at the end. This will not compile, since hakyll knows how to write a Page String, but not how to write an Either (Page String) (Page String). But that can be amended by a simple type class declaration:

instance Writable b => Writable (Either a b) where
  write p (Right b) = write p b
  write _ _ = return ()

Now hakyll will happily generate published pages and ignore unpublished ones. This solution is of course slightly wasteful, as at will apply all the templates to an unpublished page before finally discarding it. You can avoid this by using the +++ function, which does for the sum datatype Either what *** does for the product type pair:

  match "posts/*" $ do
    route   $ setExtension ".html"
    compile $ pageCompiler
       >>> isPagePublished
       >>> (id +++ (applyTemplateCompiler "templates/post.html"
                    >>> applyTemplateCompiler "templates/default.html"
                    >>> relativizeUrlsCompiler))

The other problem with this solution is more severe: hakyll will no longer generate the index and posts pages due to a rare problem in haskell land: a runtime type error. Hakyll tries to be smart and reuse the parsed pages from the match "posts/*" when processing the requireAllA "posts/*" calls by caching them. But the compilers there still expect a list of pages instead of a list of eithers, so we have to replace filterPublised with something that works on the latter. Luckily (or, probably, by design), Data.Either provides just the function we need, so the new filtering compiler is actually shorter that the original, even though it has a more intimidating type:

filterPublishedE :: Compiler (Page a, [Either (Page b) (Page b)]) (Page a, [Page b])
filterPublishedE = id *** arr rights

Timed releases

Exploiting the fact that compilers are arrows, we can do more mixing and matching of compilers to further refine how hakyll deals with page attributes like published. Maybe you want cron to update your blog while you are on vacation, so you want posts to be considered published if the published field is either true or a time in the past.

If you happen to live in the UK in winter or enjoy to do time zone calculation in your head, your new function to test if a page is published and the compiler derived from it might then look like this

isPublishedYet :: Page a -> UTCTime -> Bool
isPublishedYet page time =
  let published = getField "published" page in
  published == "true" || after published
  where
  after published =
    let publishAt = parseTime defaultTimeLocale "%Y-%m-%d %H:%M" published in
    fromMaybe False (fmap (\embargo -> embargo < time) publishAt)

isPagePublishedYet :: Compiler (Page a, UTCTime) (Either (Page a) (Page a))
isPagePublishedYet = arr (\(p,t) -> if isPublishedYet p t then Right p else Left p)

This compiler has a pair of a page and a time as its input, and we can use yet another function from Control.Arrow to construct a compiler that generates the input for it, the function &&&. It takes two compilers (arrows) with the same input type and constructs a compiler from that type to a pair of the output types of the two compilers. For the first argument we take the pageCompiler which we already call at the beginning of the page compilation. The second argument should be a compiler with the same input type as pageCompiler that returns the current time. But the current time lives in the IO monad and does not at all depend on the resource the current page is generated from, so we have to cheat a little bit by calling unsafeCompiler with a function that ignores its argument and returns an IO UTCTime, which unsafeCompiler will unwrap for us:

  match "posts/*" $ do
    route   $ setExtension ".html"
    compile $ (pageCompiler &&& (unsafeCompiler (\_ -> getCurrentTime)))
      >>> isPagePublishedYet
      >>> (id +++ ( ... as above ...))

This is all we have to change if we don't generate unpublished pages at all. If we just hide them from the lists, the call to ||| discards the information that a page is not (yet) published that was encoded in the Either. In that case we could use the setField function from Hakyll.Web.Page.Metadata to rewrite the published field of the left and right pages in isPagePublished(Yet) to canonical values that the original isPublished function called from filterPublished understands:

isPagePublishedYet = arr (\(p,t) -> if isPublishedYet p t then pub p else unpub p)
  where
    pub p = Right $ setField "published" "true" p
    unpub p = Left $ setField "published" "false" p

The final version of this code can be found in the timedblog example, together with the required import statements.