shake + lucid + hint で静的ウェブサイト生成

The English version is at Dev.


同人活動用のウェブサイトがあって今までは Jekyll で生成していました。これを Shake + Lucid + Hint で作成した生成器に置き換えました。

doujin.kakkun61.com

shakebuild.com

hackage.haskell.org

hackage.haskell.org

ソースコードはこちらです。

github.com

経緯

GitHub Pages をホストに選択したので最初は自然に Jekyll を選びました。レールに乗っているうちはいいのですが外れたことをしようとすると難しくなってきました。

どうレールを外れようとしたのかを説明するためにウェブサイトの説明をします。このウェブサイトは自サークルで発行した同人誌の紹介をするもので、同人誌ごとのページとそれを一覧するページから成ります。同人誌は即売会で頒布するためそれぞれの同人誌には即売会の情報が付随します。そうなると、ある即売会でどの同人誌が頒布されたかを表示したくなりました。つまり、即売会ごとのページとそれを一覧するページが欲しくなりました。これを実現するのは Jekyll では難しくありました*1

最初にサイトを作成したのが2018年1月ごろで、その後すぐに別のものに移ろうと Hakyll を触ったりしたのですが Hakyll の API の設計は好きになれず結局放置していました。

それでしばらく移行計画は頓挫していたのですが GHC がビルドシステムに Shake が使われているのを見てこれを使えるのじゃないかと思いました。そして調べたら Shake を利用した静的サイト生成器として RibSlick がありました。

API の好みから Slick を利用してみて、自分に必要のない部分を削いでいったらほとんど Slick がなくなったので Shake を直接使うようになりました。

構成

下記が概要図でこれからこれについて説明していきます。

f:id:kakkun61:20200925234250p:plain

まずコンテンツの変更をするたびに GHC でのコンパイルとリンクをするのは時間がかかるのでやりたくありません。そこで Haskell インタープリターを組み込むことにしました。そのライブラリーが Hint です。

流れとしては、Shake を使ってルールを書き、Hint でインタープリターを埋め込んで、実行ファイル gen を作ります。そして gen を実行してコンテンツの Haskell ソースや画像などを読み込み HTML などに変換して出力します。

Data.hs は gen の生成にも、gen が実行するインタープリターからも使うので両方から読み込みます。

Shake のルールには Make のような成果物を指定して依存解決する「後向き」と、ソースを指定する「前向き」とがあります。今回は前向きを使用しています。「前向き」の場合は別途 Filesystem Access Tracer(fsatrace)が必要です。

実装

ディレクトリー構成は次のようにしました。

  • app
    • gen.hs — 生成器本体
  • content
    • book
      • xxx.hs — 同人誌ごとのページ
    • image
    • lib
      • Layout.hs — コンテンツを囲むレイアウト
    • style
    • xxx.hs — その他のページ
  • lib
  • doujin-site.cabal

gen のさわりはこの辺りです。

lucid :: forall p r. (Show p, Typeable r) => FilePath -> FilePath -> p -> Shake.Action r
lucid source destination param = do
  libs <- Shake.getDirectoryFiles "content/lib" ["*.hs"]
  result <- liftIO $ Hint.runInterpreter $ do
    Hint.set [Hint.languageExtensions := [Hint.DuplicateRecordFields, Hint.OverloadedStrings]]
    Hint.loadModules $ ("content" </> source) : (("content/lib" </>) <$> libs)
    Hint.setTopLevelModules ["Main"]
    Hint.setImports ["Data.Functor.Identity", "Lucid", "Data.Text"]
    Hint.interpret ("render (" ++ show param ++ ")") (Hint.as :: Lucid.Html r)
  case result of
    Left e  -> do
      liftIO $ hPutStrLn stderr $ displayException e
      fail "interpret"
    Right html -> do
      Shake.writeFile' ("out" </> destination) $ show html
      pure $ runIdentity $ Lucid.evalHtmlT html

大して難しいことはしていませんが、1つ制約があって、それは show param の結果が妥当な Haskell コードになっていることです。自動導出していれば問題ないはずですが、自前で show を実装していると動作しないかもしれません。

コンテンツの Haskell ソースは render :: Typeable r => p -> Html r を露出していることにしています。book/xxx.hs は下記の形をしています。

{-# LANGUAGE OverloadedStrings #-}

import           Data
import qualified Layout as L

import Lucid

render path = do
  L.top (L.ogp ogp) $ L.book book (Just content)
  pure book
  where
    ogp = …
    book = …
    content =

返り値の Book はそれぞれのページの分が集められた後 index.hs に渡され一覧ページの作成に使用されます。

{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE NamedFieldPuns        #-}
{-# LANGUAGE OverloadedStrings     #-}

import           Data
import qualified Layout as L

import Data.Foldable as F
import Lucid

render (path, books) =
  L.top
    (L.ogp ogp)
    $ div_ [class_ "home"] $ do
        ul_ [class_ "book-list"] $ do
          F.for_ books $ \(Book { title, bookImage, events }, path) -> do
            li_ $ do
              h3_ $
                a_ [class_ "post-link", href_ path] $ toHtml title
              div_ [class_ "justify-bottom"] $ do
                ul_ [class_ "event-badges"] $ do
                  F.for_ events $ \Event { title } -> do
                    li_ [class_ "event-badge"] $ toHtml title
                    " "
                a_ [href_ path] $ img_ [src_ bookImage, alt_ "book image", class_ "home-book-front"]
  where
    ogp =

HTML の記述には Lucid を使用しました。内部 DSL なのでHaskell コードと統一した記法で埋め込めるので楽です。ここは好みで取り替えができます。

ここまで来れば即売会ページとその一覧ページを作るのも簡単そうです。

まとめ

  • Shake を依存関係記述に使う
  • Hint でインタープリターを埋め込む
  • Lucid で HTML を記述する
    • EDSL なので統一した記法で書ける

感想としては、型にはまったフレームワークでコンテンツを書くよりも、パーツとしてのライブラリーを組み合わせて作る方が柔軟性が高くて好みだなと再確認しました。

追記

2020.09.27

テンプレートリポジトリーにしました。

github.com

*1:少なくとも当時の Jekyll では。