Hakyllで個人ウェブページを作りましたので全体概要を紹介

Rokiです。 先日、ふと思い立って、Haskell製静的サイト生成ライブラリHakyllで個人ウェブページを作成しました。 Hakyllでのウェブページ作成方法の体系的な情報は既にインターネット上に多く存在していると思われますので、今回はウェブページの作成過程で行なったことについて、その全体概要を紹介してみたいと思います。

Hakyll

冒頭で述べたように、HakyllはHaskell製の静的サイト生成ライブラリです。

公式サイト:HAKYLL

一般に静的サイト生成と聞きますと既存のテンプレートやプラグインがいくつか存在して、それのうちの何かを選んで再利用したり、はたまたテンプレートを自作してそれを利用したり、というイメージがあるかもしれませんがHakyllはあくまでも静的サイト生成器そのものというよりかは、それを作るためのライブラリであるという点で特徴的かもしれません。

Hakyllでウェブページを作ることの直感的な"面白さ"を1つ挙げるとするならば、凝ろうと思えばいくらでも凝れる柔軟性が言えるのではないでしょうか。 その柔軟性について紹介するために、以下hakyll-initによって生成されるsite.hsの中身の一部を示します。

    match "images/*" $ do
        route   idRoute
        compile copyFileCompiler

    match "css/*" $ do
        route   idRoute
        compile compressCssCompiler

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

    match "index.html" $ do
        route idRoute
        compile $ do
            posts <- recentFirst =<< loadAll "posts/*"
            let indexCtx =
                    listField "posts" postCtx (return posts) `mappend`
                    defaultContext

            getResourceBody
                >>= applyAsTemplate indexCtx
                >>= loadAndApplyTemplate "templates/default.html" indexCtx
                >>= relativizeUrls

    match "templates/*" $ compile templateBodyCompiler

これは、それぞれのページを生成する際のルールが書かれています。 例えば、以下のルールについて見てみます:

    match "images/*" $ do
        route   idRoute
        compile copyFileCompiler

このルールの意味するところは、プロジェクトルートのimagesディレクトリ以下のコンテンツを 出力先においてもそのままのパス(つまりウェブサイトルートのimagesディレクトリ)に置くことです。 続いて、以下のルールについて見てみます:

    match "index.html" $ do
        route idRoute
        compile $ do
            posts <- recentFirst =<< loadAll "posts/*"
            let indexCtx =
                    listField "posts" postCtx (return posts) `mappend`
                    defaultContext

このルールの意味するところは、プロジェクトルートのindex.htmlに対し、まず出力先のパスはそのまま、 そしてプロジェクトルートのpostsディレクトリ以下のファイル全てに対して日付順(昇順)で読み込み、 postsというコンテキストをdefaultContextに加えてテンプレートに渡すことです。 コンテキストという抽象的な何かが突然出てきましたが、 直感的に言えば、これはHTMLテンプレートの内部から呼び出すことのできる何かです。 例えば、先に生成されたindex.htmlの中身(一部)を確認しますと:

<p>I've reproduced a list of recent posts here for your reading pleasure:</p>

<h2>Posts</h2>
$partial("templates/post-list.html")$

となっており、templates/post-list.htmlを見てみると以下のように使われていることが確認できます。

<ul>
    $for(posts)$
        <li>
            <a href="$url$">$title$</a> - $date$
        </li>
    $endfor$
</ul>

このように、Hakyllでウェブサイトを作るということは、上記のようなルールを羅列して静的サイト生成器を作り、 テンプレートを作成して適用するということに他ならないのですが、 ここで、このルールそのものがHaskellのソースコードであるという点は強調すべき点です (Hakyllがというよりかは、Haskellの文法そのものがこのようなDSLっぽい見た目を実現させています)。 これにより、ウェブサイトを生成する前の任意の処理をHaskellでそのまま書くことができ、これが例えば以下のような工夫を可能にします。

  • ウェブアイコンのFont Awesomeやウェブ上で数式を表示できるKaTeXのSVGをウェブサイトの生成時に埋め込む。これにより、動的にレンダリングするよりも良い表示パフォーマンスを期待できる
  • ブログ記事のコンテンツ内容からjsライブラリ読み込みの必要性、不必要性を判定しjsファイルの読み込みコードを埋め込むか、埋め込まないかを切り替える。これにより、不要なjsファイル読み込みを防ぐ。例えば、以下のように判定

pluginCtx :: MonadMetadata m => [Item a] -> String -> m (Context b)
pluginCtx posts pluginName = ifM
    (isJust <$> findM (fmap isJust . flip getMetadataField pluginName . itemIdentifier) posts)
    (return $ boolField pluginName (const True))
    (return mempty)

これらの工夫は今回実際に個人ウェブページを作る際に行ったものですが、 この他にも柔軟に、様々なニーズに応じた静的サイト生成器が作成できるでしょう。

最終的に完成した個人ブログ

Programmable configuration language Dhall

今回作った個人ウェブページの一部では、内容を書き換えやすいよう、 設定ファイルから項目を読み込んで適用する形をとっている箇所があります。 設定ファイルの形式は様々ありますが、今回はDhallという言語を使っています。 Dhallは設定ファイルを記述するための言語で、yamlやjsonなどにはない型、関数、インポートといった機能があります (あくまで設定ファイルを記述するための言語なこともあり、非チューリング完全です)。 設定ファイルを記述するときに出てくる不満としては、以下が挙げられると思います。

  • 同じ記述、値を繰り返し書きたくない
  • 意図しない変な値やタイポ等を静的に検出したい
  • 巨大な設定ファイルを分割したい

Dhallは型、関数、インポートといった機能でこれらの不満をうまくカバーしてくれます(と思っています)。 それも、設定ファイルとしての機能をこちらは期待していますので、 何か副作用が含まれていたり無限ループが書けたりなどしてしまうと逆に不便なのですが、 その辺りの領分がうまく守られているようです。 イメージがお伝えできるよう実際に今回使っているコードの一部分を示していきたいと思います。 以下は、GenreというUnionを定義し、それぞれに文字列に対応する射を定義しています。 これにより、予め定義された値からのみしか設定できないようにすることができます。

let Genre_ = < Haskell : {} | Cpp : {} | JavaScript : {} | Rust : {} | Go : {} >

in  let genreHandler =
          { Haskell = λ(_ : {}) → "Haskell"
          , Cpp = λ(_ : {}) → "C++"
          , JavaScript = λ(_ : {}) → "JavaScript"
          , Rust = λ(_ : {}) → "Rust"
          , Go = λ(_ : {}) → "Go"
          }

    in  { Genre = Genre_, genreToText = λ(g : Genre_) → merge genreHandler g }

これをGenre.dhallというファイルに保存しているとすると、例えば以下のように使うことが出来ます。

let g = ./Genre.dhall

in    [ { projName = "htcc"
        , lang = g.genreToText (g.Genre.Haskell {=})
        , projLink = "https://github.com/falgon/htcc"
        , summary = "A full scratch, tiny C language compiler."
        }
       ]

他の利用例としては、例えばdocker-compose.ymlをDhallで記述するといったものも挙げられると思います。 以下の内容は私の別プロジェクトのテスト実行で利用しているdocker-compose.dhallのスニペットです。

let types =
      https://raw.githubusercontent.com/falgon/dhall-docker-compose/master/compose/v3/types.dhall

let defaults =
      https://raw.githubusercontent.com/falgon/dhall-docker-compose/master/compose/v3/defaults.dhall

let htccService =
        defaults.Service
      ⫽ { image = Some "roki/htcc_test:1.0.0"
        , command = Some
            ( types.StringOrList.String
                "/bin/sh -c 'gcc -no-pie -o spec /htcc_work/spec.s && ./spec'"
            )
        , volumes = Some [ "/tmp/htcc:/htcc_work" ]
        , build = Some
            ( types.Build.Object
                { context = "."
                , dockerfile = "./docker/Dockerfile"
                , args =
                    types.ListOrDict.List
                      ([] : List (Optional types.StringOrNumber))
                }
            )
        }

let services
    : types.Services
    = [ { mapKey = "htcc", mapValue = htccService } ]

in  defaults.ComposeConfig ⫽ { services = Some services } : types.ComposeConfig

yaml形式への変換ツールであるdhall-to-yamlを利用すれば、 例えば以下のようにして直接実行することが可能です。

$ dhall-to-yaml < in.dhall | docker-compose -f - up --build

なおHaskellに対してはバインドが用意されていますので、 Dhallからyamlへの変換、dockerプロセスの起動を例えば以下のように記述できたりします。

createDockerProcessWithDhall :: FilePath -> String -> IO ()
createDockerProcessWithDhall fp cmd = T.readFile fp
    >>= dhallToYaml (defaultOptions { explain = True, omission = omitNull }) (Just fp)
    >>= readCreateProcess (shell $ "docker-compose -f - " <> cmd) . decodeString . B.unpack
    >>= putStrLn

createDockerProcessWithDhall "input.dhall" "up --build"

CI/CDによる管理

Dhallの話題から戻って、個人ウェブページの管理についても紹介したいと思います。 今回作ったウェブページはGitHub Pagesでホスティングしており、ビルド、テスト、デプロイ等はGitHub Actionsで行っています。 ただし、記事の下書きを非公開でリモートにプッシュしておきたかったため、これを実現するためにブログの下書き用のリポジトリを別に作成し、 そのリポジトリ内で本番用のブランチにマージした段階で公開されているリポジトリに自動でプッシュし、ウェブサイトのビルドが走る、という管理方式をとることにしました。

CI/CD 概略図

このようにしておくと、下書きとしてリモートに保存できるということの他に、ブログ記事そのものに対する管理と、ウェブサイトを生成するアプリケーションの管理を分離することができるという利点があります。 例えば、ブログ記事の管理のほうで扱われるのはMarkdownテキスト、画像などのメディアファイル、記事内で使うようなjsファイル等で、 これらのlinterやメディアファイルへの自動圧縮などのCI/CDやボットは、アプリケーション(静的サイト生成器)の実装に対しては用いられません。 これを今回リポジトリを分けたことで、それぞれの役割毎に完全に分離できたので、CI/CDやボットの整理がしやすくなりました。

その他、外部の依存パッケージはDependabotというBotに管理させるようしているのですが、 今回のようなウェブサイトのプロジェクトの場合、PRマージ後のウェブページを一度実際にプレビューしておきたいといった理由で、 Dependabotから投げられてきたPRをただそのままマージするといったことが出来ない場合があります。 手元にプルしてきて毎度確かめればそれは勿論PRマージ後のウェブページをプレビューできるのですが、出来る限り作業量を減らしたかったので Circle CIのArtifactsを用いて、PR(のコミット)毎にプレビューを閲覧できるようにしてみました。

CircleCI Artifacts の利用

PRが飛んできますと、GitHub Actionsがまずウェブサイトのビルドを開始し、 Google Driveにそのtarballをアップロードします(ファイル名にコミットハッシュを付与します)。 その後CircleCIのAPIを叩き、CircleCIのジョブを起動します。 CircleCIはいまアップロードされたtarballをコミットハッシュを参考にダウンロードし、 Artifactsに展開します。その後、CircleCIからGitHubのAPIを叩き、BotがそのCircleCIのログページURLと、 Artifactsのindex.htmlのURLを該当PRにコメントします。 これにより、手元で毎度プルして確認しなくとも、PRの変更によって実際はどのように影響が及ぶのか、 簡易的にプレビューすることができます。

この他、 色々と既存のBotやサービスを利用しているのですが、 その1つとしてrestyledというコードをフォーマッタ等で整形したものをPRで送ってくれるBotがあります。 ただDhallが未対応だったので今回はPRを送って追加して頂きました。

おわり

ウェブサイトを構成する全体のソースコード類はこちらのリポジトリで管理しています。 ウェブサイト生成においてはテンプレートHTMLのLucid化等、まだまだ拘れる点は多くあると思います。 如何に無駄のないHTMLを出力できるか試行錯誤したり、様々な自動化や管理手法を考えて構成するのは面白い作業でした。

このブログについて

KLabのゲーム開発・運用で培われた技術や挑戦とそのノウハウを発信します。

おすすめ

合わせて読みたい

このブログについて

KLabのゲーム開発・運用で培われた技術や挑戦とそのノウハウを発信します。