ここを Hakyll から Slick に移行してみた
特に深い理由はないですが,新しいツールを触ってみようかと思い変えてみました。
Slick
Slick は Hakyll と同じような Haskell 製の静的サイトジェネレーターで,サイトの生成方法自体を自身でプログラミングする. GitHub の README 曰く,Hakyll はモナドに隠蔽されすぎてよくわからないから,もっとわかりやすいのを作った(超意訳)だそうだ. 実際,両方同じようなコードを書いてみた感じ,確かに Slick の方がわかりやすい(シンプル).
Slick は内部的な処理の多くを外部パッケージに委ねている:
- ビルドシステムには Shake を利用している(提供するサブコマンドやビルド結果のキャッシュなど)
- Markdown から HTML への変換は Pandoc を利用している(Hakyll と同じ)
- テンプレートのレンダリングには Mustache を利用している
それぞれについては,あまり詳しいことを僕は知らないので,ここでは解説しません.
カスタマイズする
Slick の作者は ChrisPenner/slick-template というテンプレートリポジトリを用意しているので,これをベースにカスタマイズしていく. 正直なところ,半分は元の Hakyll でのテンプレートを再現するため.
extensible レコード
まずはいきなりテンプレートの再現ではないやつ.
slick-template で使っていたレコード型を extensible レコードに置き換える. 例えば:
type SiteMeta = Record
"siteTitle" >: String
'[ "domain" >: String
, "author" >: String
, "description" >: String
, "twitter" >: Maybe String
, "github" >: Maybe String
,
]
type Post = Record ('[ "date" >: String, "url" >: String ] ++ FrontMatterParams)
type FrontMatterParams =
"title" >: String
'[ "tags" >: [Tag]
, "image" >: Maybe String
, "content" >: String
, ]
フロントマターの部分だけ分けてるのは後述.
extensible を使うのに利点はあって,slick-template では substitute
に渡す ToMustache k
の値を結合するときに aeson の Value
型に変換して無理やり足しているが,extensible レコードであれば happend
だけですむ. 無論このためには extensible レコードを ToMustache
型クラスのインスタンスにする必要がある:
deriving instance ToMustache (h (TargetOf kv)) => ToMustache (Field h kv)
deriving instance ToMustache a => ToMustache (Identity a)
instance Forall (KeyTargetAre KnownSymbol (Instance1 ToMustache h)) xs => ToMustache (xs :& Field h) where
= Object . hfoldlWithIndexFor
toMustache Proxy @ (KeyTargetAre KnownSymbol (Instance1 ToMustache h)))
(-> HM.insert (stringKeyOf k) (toMustache v) m)
(\k m v HM.empty
また,Shake のキャッシュ(cacheAction
)を利用するには生成物の型(例えば Post
)が Binary
型クラスのインスタンスになってないといけない:
deriving instance Binary (h (TargetOf kv)) => Binary (Field h kv)
instance Forall (KeyTargetAre KnownSymbol (Instance1 Binary h)) xs => Binary (xs :& Field h) where
= hgenerateFor
get Proxy @ (KeyTargetAre KnownSymbol (Instance1 Binary h)))
(const Binary.get)
(
= flip appEndo (return ()) . hfoldMap getConst .
put
hzipWithComp Dict) x -> Const $ Endo $ (Binary.put x >>))
(\(library :: xs :& Comp Dict (KeyTargetAre KnownSymbol (Instance1 Binary h))) (
これは過去に extensible 本体にあったインスタンスを参考にした(今は実装されてない,理由は知らない). 一応 decode . encode == id
という性質は満たしているっぽいので大丈夫だろう.
さて,例えば以上を踏まえて buildPost
を書き換えると次のようになった(元はこんな感じ):
buildPost :: FilePath -> Action Post
= cacheAction ("build" :: T.Text, srcPath) $ do
buildPost srcPath <- readFile' srcPath
postContent <- markdownToHTML' @(Record FrontMatterParams) (T.pack postContent)
postData let postUrl = dropDirectory1 (srcPath -<.> "html")
= happend siteMeta $ #url @= postUrl <: #date @= "..." <: postData
postData' <- compileTemplate' "site/templates/post.html"
template </> postUrl) $ T.unpack (substitute template postData')
writeFile' (outputFolder convert postData'
日付(date
フィールド)については後述. markdownToHTML' :: FromJSON a => Text -> Action a
は本文を Markdown から HTML に変換して型 a
の content
フィールドへ格納し,残りのフィールドをフロントマターとしてパースする. TypeApplication
言語拡張でフロントマターの型を明記してるのは,具体的な型がはっきりしていないと happend
できないからだ. ちなみに,今回定義した FrontMatterParams
型はタイトルとタグとサムネイル用画像をフロントマターとして与えている.
記事のパスから投稿日を出す
slick-template では投稿日をフロントマターで指定していたが,このサイトでは記事のパス(YYYY/MM-DD-name.md
)で指定していた. なので,そのような動作をするように修正する:
buildPost :: FilePath -> Action Post
= cacheAction ("build" :: T.Text, srcPath) $ do
buildPost srcPath <- readFile' srcPath
postContent <- markdownToHTML' @(Record FrontMatterParams) (T.pack postContent)
postData -- YYYY/MM-DD-name.md から YYYY-MM-DD-name.html にしている
let postUrl = dropDirectory1 (takeDirectory srcPath <> "-" <> takeFileName srcPath -<.> "html")
= happend siteMeta $ #url @= postUrl <: #date @= formatToHumanDate srcPath <: postData
postData' ... -- 割愛
formatToHumanDate :: FilePath -> String
= formatTime defaultTimeLocale "%b %e, %Y" parsedTime
formatToHumanDate p where
= parseTimeOrError True defaultTimeLocale "%Y-%m-%d" (year <> "-" <> date) :: UTCTime
parsedTime = take 5 $ takeFileName p
date = takeFileName $ takeDirectory p year
ちなみに,このパス操作系の関数は Development.Shake.FilePath
にあるのを利用している.
ページネーション
slick-template では,記事の一覧がインデックスページにズラーっといくらでも並ぶようになっている. これを10記事ぐらいずつに分けて表示できるようにする:
buildArchive :: [Post] -> Action ()
= do
buildArchive posts <- compileTemplate' "site/templates/archive.html"
archiveT -- posts が古い順なので reverse している
reverse posts) (outputFolder </> "archive")
buildWithPagenation archiveT siteMeta (
buildWithPagenation :: Forall (KeyTargetAre KnownSymbol (Instance1 ToMustache Identity)) (xs ++ PagenationInfoParams)
=> Template
-> (xs :& Field Identity)
-> [Post]
-> FilePath
-> Action ()
= go 1 posts
buildWithPagenation t r posts dir where
= 10
pageSize
go :: Int -> [Post] -> Action ()
= pure ()
go _ [] = do
go n posts' let info = #posts @= take pageSize posts'
<: #prevPageNum @= guarded (> 0) (n - 1)
<: #nextPageNum @= guarded (const $ length posts' > pageSize) (n + 1)
<: nil
</> show n -<.> "html") $ T.unpack (substitute t $ happend r info)
writeFile' (dir + 1) (drop pageSize posts')
go (n
guarded :: (a -> Bool) -> a -> Maybe a
= if p a then Just a else Nothing guarded p a
buildWithPagenation
がページネーションしてくれる本体で,あとでタグページでも利用したいので別関数に切り出している. 単純に posts
を分割するだけではダメで,現在と前後のページ番号をテンプレートに渡してあげる必要がある. そのために簡単な再帰処理をしている.
ちなみに,buildWithPagenation
の型が仰々しいのは happend
するメタデータを任意の extensible レコードにしたかったからだ. 型パズルに悩んだ結果,型を書かないときに HLS がサジェストしてくれた型をそのまま書いたら通った(パズルできてないじゃん). HLS 最高.
タグページ
slick-template では,タグをフロントマターに記述できるようになってはいるものの,タグページはないので自作した:
buildTagPages :: [Post] -> Action [(Tag, Int)]
= do
buildTagPages posts <- compileTemplate' "site/templates/tags.html"
tagT $ \(tag, posts') -> do
forM (groupByTag posts) #tag @= tag <: siteMeta) posts' (outputFolder </> "tags" </> tag)
buildWithPagenation tagT (pure (tag, length posts')
groupByTag :: [Post] -> [(Tag, [Post])]
= HML.toList . foldl go mempty
groupByTag where
go :: HML.HashMap Tag [Post] -> Post -> HML.HashMap Tag [Post]
=
go acc post foldl (\acc' tag -> HML.insertWith (++) tag [post] acc') acc (post ^. #tags)
前述したとおり,こっちでも buildWithPagenation
を使っているが,siteMeta
の他にタグの情報もテンプレートに渡したかったので仰々しい型にしたのだ. buildTagPages
がタグ情報を返しているのはインデックスページに Hakyll のタグクラウドを設定したいからだ:
buildIndex :: [(Tag, Int)] -> [Post] -> Action ()
= do
buildIndex tags posts <- compileTemplate' "site/templates/index.html"
indexT let indexHTML = T.unpack $ substitute indexT (happend siteMeta indexInfo)
</> "index.html") indexHTML
writeFile' (outputFolder where
= #tags @= tagsInfo <: #posts @= take 4 (reverse posts) <: nil :: IndexInfo
indexInfo
= map (uncurry toTagInfo) (L.sortOn fst tags)
tagsInfo = maximum $ map snd tags
minCnt = minimum $ map snd tags
maxCnt
toTagInfo tag n= #name @= tag
<: #size @= calcSize 120.0 80.0 n minCnt maxCnt
<: nil
calcSize :: Double -> Double -> Int -> Int -> Int -> Int
=
calcSize minSize maxSize cnt min' max' let diff = 1 + fromIntegral max' - fromIntegral min'
= (fromIntegral cnt - fromIntegral min') / diff
relative in floor $ minSize + relative * (maxSize - minSize)
tagsInfo
周りの処理は Hakyll のコードを参考にして書いただけ.
シンタックスハイライト
slick-template では,シンタックスハイライトを自前の CSS で定義していたが,ここでは skylighting パッケージのを利用していたいたのでそうする:
buildHighlightCss :: Action ()
=
buildHighlightCss </> "css" </> "highlight.css") $ styleToCss pygments writeFile' (outputFolder
おしまい
思ったよりさくっとできた.