Haskell
Hakyll

Hakyllで前後の記事へのリンクを設置する

More than 1 year has passed since last update.

普通のブログには、各記事の最初か最後に、前後の記事へのリンクが設置されていると思います。

しかし、(Haskellerの)ブログに使われることが多いと思われる静的サイトジェネレーターHakyllのデフォルトでは、前後の記事へのリンクが設置されません。Hakyll自身のチュートリアルにも、前後の記事へのリンクはありません。

個々の記事の内容が独立しているような静的サイトなら前後に辿れなくてもいいかもしれませんが、内容が連続している場合(チュートリアルなど)はそういうリンクが欲しいところです。

Hakyllには前後の繋がりを持ったページの列を作るために Hakyll.Web.Paginate というモジュールが用意されていますが、これは個々の記事に対してというよりも、「記事 k 個ごとの一覧(アーカイブ)」を作成することを想定したもののように見えます。

まあでも頑張ったらできました。

site.hs: ビフォー

hakyll-init で吐かせたデフォルトの site.hs では次のようになっているところを、

site.hs
main = hakyll $ do
    ......
    match "posts/*" $ do
        route $ setExtension "html"
        compile $ pandocCompiler
            >>= loadAndApplyTemplate "templates/post.html"    postCtx
            >>= loadAndApplyTemplate "templates/default.html" postCtx
            >>= relativizeUrls

site.hs: アフター

次のように書き換えます:

site.hs
main = hakyll $ do
    ......
    -- 記事一覧を取得
    postIDs <- sortChronological =<< getMatches "posts/*"
    let makeId pageNum = postIDs !! (pageNum - 1)
        grouper items = return (map (:[]) items) -- one item per group
        pageTitle :: Int -> Compiler String
        pageTitle n | 1 <= n && n <= length postIDs = do
                        mtitle <- getMetadataField (makeId n) "title"
                        case mtitle of
                            Just title -> return title
                            Nothing -> fail "no 'title' field"
                    | otherwise = fail "unavailable"
    pag <- buildPaginateWith grouper "posts/*" makeId
    paginateRules pag $ \pageNum pattern -> do
        let ctx = mconcat [ field "previousPageTitle" (\_ -> pageTitle (pageNum - 1))
                          , field "nextPageTitle"     (\_ -> pageTitle (pageNum + 1))
                          , paginateContext pag pageNum
                          , postCtx
                          ]
        -- あとはいつも通り(ただしテンプレートに与える Context を変える)
        route $ setExtension "html"
        compile $ pandocCompiler
            >>= loadAndApplyTemplate "templates/post.html"    ctx
            >>= loadAndApplyTemplate "templates/default.html" ctx
            >>= relativizeUrls

前後の記事のタイトルに相当するフィールドは用意されていないので、自分で実装してやります。

「記事 k 個ごとのアーカイブ」を作る場合は pageNum 引数を元に、アーカイブのURL /archives/NN/ を組み立てるのかもしれませんが、今回の使い方ではページ番号を数値として使っていません。

site.hs: アフター(別解)

Hakyll.Web.Paginate を使わない例は以下のようになります:

site.hs
main = hakyll $ do
    ......
    -- 記事一覧を取得
    postIDs <- sortChronological =<< getMatches "posts/*"
    let nextPosts = tail $ map Just postIDs ++ [Nothing]
        prevPosts = Nothing : map Just postIDs

        pageTitle, pageUrl :: Identifier -> Compiler String
        -- 記事IDに対する 'title' 属性を取得する
        pageTitle i = do
            mtitle <- getMetadataField i "title"
            case mtitle of
                Just title -> return title
                Nothing -> fail "no 'title' field"
        -- 記事IDに対するURLを取得する
        pageUrl i = do
            mfilePath <- getRoute i
            case mfilePath of
                Just filePath -> return (toUrl filePath)
                Nothing -> fail "no route"

    -- (記事のID,次の記事のID,前の記事のID) の3つ組について処理を行う
    forM_ (zip3 postIDs nextPosts prevPosts) $
        \(postID,mnextPost,mprevPost) -> create [postID] $ do
            -- 以下、テンプレートの中で変数 previousPageUrl, previousPageTitle, nextPageUrl, nextPageTitle を使えるようにするための定義
            let prevPageCtx = case mprevPost of
                    Just i -> field "previousPageUrl"   (\_ -> pageUrl   i) `mappend`
                              field "previousPageTitle" (\_ -> pageTitle i)
                    _ -> mempty
                nextPageCtx = case mnextPost of
                    Just i -> field "nextPageUrl"       (\_ -> pageUrl   i) `mappend`
                              field "nextPageTitle"     (\_ -> pageTitle i)
                    _ -> mempty
                ctx = prevPageCtx `mappend` nextPageCtx `mappend` postCtx
            -- 以下同じなので省略

Paginate を使った実装で "posts/*" を2回書いていたり、リストにインデックスでアクセスしているのがイケてない、と思う方はこっちを使うといいと思いますが、正直どうでもいいです。

関数化

main 関数の中にこれらの処理を書いてしまうと見通しが悪いので、関数に分けてみました。

makeSeries :: [Identifier] -> (Context String -> Rules ()) -> Rules ()
makeSeries postIDs rule = do
  let nextPosts = tail $ map Just postIDs ++ [Nothing] :: [Maybe Identifier]
      prevPosts = Nothing : map Just postIDs           :: [Maybe Identifier]
  forM_ (zip3 postIDs nextPosts prevPosts) $ \(postID,mnextPost,mprevPost) -> create [postID] $ do
      let siblingCtx = mconcat $ catMaybes [(field "previousPageUrl"   . pageUrlOf)   <$> mprevPost
                                           ,(field "previousPageTitle" . pageTitleOf) <$> mprevPost
                                           ,(field "nextPageUrl"       . pageUrlOf)   <$> mnextPost
                                           ,(field "nextPageTitle"     . pageTitleOf) <$> mnextPost
                                           ]
      rule siblingCtx
        where
          pageTitleOf, pageUrlOf :: Identifier -> Item a -> Compiler String
          pageTitleOf i _item = do
            mtitle <- getMetadataField i "title"
            case mtitle of
              Just title -> return title
              Nothing -> fail "no 'title' field"
          pageUrlOf i _item = do
            mfilePath <- getRoute i
            case mfilePath of
              Just filePath -> return (toUrl filePath)
              Nothing -> fail "no route"

使用例は

main = hakyll $ do
  postIDs <- sortChronological =<< getMatches "posts/*"
  makeSeries postIDs $ \siblingCtx -> do
    route $ setExtension "html"
    let ctx = siblingCtx `mappend` postCtx
    compile ...

です。

テンプレートでの使用例

使い方としては、投稿用のテンプレートに

templates/post.html
$if(previousPageUrl)$
<a href="$previousPageUrl$">前の記事</a>
$endif$

$if(nextPageUrl)$
<a href="$nextPageUrl$">次の記事</a>
$endif$

または

templates/post.html
$if(previousPageUrl)$
<a href="$previousPageUrl$">前:$previousPageTitle$</a>
$endif$

$if(nextPageUrl)$
<a href="$nextPageUrl$">次:$nextPageTitle$</a>
$endif$

と書き込んでやれば、それが前後の記事へのリンクになります。

普通のブログだと「前」「次」ではなく「古い」「新しい」の方がいいかもしれませんが、そこは適当にしてください。

おまけ

site.hs を何回もいじって編集する場合は、 stack build --fast で最適化を無効にする、.cabalの ghc-options-dynamic を指定して動的リンクさせるなどの措置をとると、 stack build の時間を削減できて良いと思います。

関連

Hakyllでページング