shake + lucid + hint で静的ウェブサイト生成
The English version is at Dev.
同人活動用のウェブサイトがあって今までは Jekyll で生成していました。これを Shake + Lucid + Hint で作成した生成器に置き換えました。
ソースコードはこちらです。
経緯
GitHub Pages をホストに選択したので最初は自然に Jekyll を選びました。レールに乗っているうちはいいのですが外れたことをしようとすると難しくなってきました。
どうレールを外れようとしたのかを説明するためにウェブサイトの説明をします。このウェブサイトは自サークルで発行した同人誌の紹介をするもので、同人誌ごとのページとそれを一覧するページから成ります。同人誌は即売会で頒布するためそれぞれの同人誌には即売会の情報が付随します。そうなると、ある即売会でどの同人誌が頒布されたかを表示したくなりました。つまり、即売会ごとのページとそれを一覧するページが欲しくなりました。これを実現するのは Jekyll では難しくありました*1。
最初にサイトを作成したのが2018年1月ごろで、その後すぐに別のものに移ろうと Hakyll を触ったりしたのですが Hakyll の API の設計は好きになれず結局放置していました。
それでしばらく移行計画は頓挫していたのですが GHC のビルドシステムに Shake が使われているのを見てこれを使えるのじゃないかと思いました。そして調べたら Shake を利用した静的サイト生成器として Rib と Slick がありました。
API の好みから Slick を利用してみて、自分に必要のない部分を削いでいったらほとんど Slick がなくなったので Shake を直接使うようになりました。
構成
下記が概要図でこれからこれについて説明していきます。
まずコンテンツの変更をするたびに 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 — その他のページ
- book
- lib
- Data.hs — gen の生成にもインタープリターからも使用するデータ型
- 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 コードと統一した記法で埋め込めるので楽です。ここは好みで取り替えができます。
ここまで来れば即売会ページとその一覧ページを作るのも簡単そうです。
まとめ
感想としては、型にはまったフレームワークでコンテンツを書くよりも、パーツとしてのライブラリーを組み合わせて作る方が柔軟性が高くて好みだなと再確認しました。
追記
2020.09.27
テンプレートリポジトリーにしました。
*1:少なくとも当時の Jekyll では。