🍵

elm-pagesでシンプルな自作ブログサイトを作った

に公開

elm-pages, MicroCMS, Vercelを使ってJAMstackなブログサイトを作ってみました。
https://yomek33.vercel.app/
https://github.com/yomek33/elm-blog

Elmについて

ElmはJavaScript にコンパイルできる関数型プログラミング言語です。フロントエンドの実装のしやすさや型安全性などの文脈でよく耳にしていたので、ブログ作成に使ってみました。
私自身はこれまでElmを書いたことはありません。他の関数型言語はちょっと書いたことがありました。

学習に役立ったサイト、動画

https://guide.elm-lang.jp/
https://www.youtube.com/watch?v=WgJ2FUW1miA&list=PLuGpJqnV9DXq_ItwwUoJOGk_uCr72Yvzb

Elm-pages v3とは

https://elm-pages.com/
elm-pagesは、Elm製のフロントエンドフレームワークです。
ファイルベースのルーティングを採用しており、ページごとのファイル構造でルートが自動的に決まります。
v2までは主に静的サイト生成(SSG)向けでしたが、v3からは動的なサーバーサイドレンダリング、Cookie認証、バックエンドでのデータ取得、フォーム送信など、フルスタックな機能も提供しています。
詳しくは: https://elm-pages.com/blog/introducing-v3/

今回は静的サイトジェネレータ(SSG)として使いました。Home画面(記事一覧)、About画面、カテゴリページなど、基本的な構成を備えたミニマルなブログです。
Elmといえば通常はThe Elm Architectureを採用して動的なSPAを構築することが多いですが、今回はSSGとして利用したため、典型的なModel VIew Updateの流れはあまり前面に出ていません。

開発する上で重要だった点や詰まった点について簡単に整理しました。

ディレクトリ構成

今回作ったブログのディレクトリ構成は次のようになりました。

app
├── Api.elm
├── Component
│   └── List.elm
├── Effect.elm
├── ErrorPage.elm
├── HtmlParser.elm
├── Microcms.elm
├── Route
│   ├── About.elm
│   ├── Blog
│   │   └── Slug_.elm
│   ├── Category
│   │   └── Slug_.elm
│   └── Index.elm
├── Shared.elm
├── Site.elm
└── View.elm

ファイルベースでルーティングされます。
app/Route/Index.elm: / 記事一覧
app/Route/About.elm: /about
app/Route/Blog/Slug_.elm: /blog/:slug 記事ごとの画面

File-based Routing
https://elm-pages.com/docs/file-based-routing

Shared.elm

Shared.elmは、アプリ全体で共通して使う設定やレイアウト、状態管理、更新処理、そしてビューのテンプレートを定義しています。view関数では、アプリ全体に共通するレイアウトを設定します。
記事一覧画面や記事画面に共通するHeaderを設定するためにこのように書いてみました。

view :
    Data
    ->
        { path : UrlPath
        , route : Maybe Route
        }
    -> Model
    -> (Msg -> msg)
    -> View msg
    -> { body : List (Html msg), title : String }
view _ _ _ _ pageView =
   { body =
        [  viewHeader
        , Html.main_ [] pageView.body
        ]
    , title = pageView.title
    }

viewHeader : Html msg
viewHeader =
    Html.header [] [
        div [class "header-content"][
            div [class "title"][link [] [ text "Home" ] Index]
        ,   div [class "about"][link [] [ text "About" ] About]
        ]
    ]

pageViewは、各ページ固有の内容を持つビュー(たとえば、記事本文やタイトル)です。
viewHeaderを見てわかる通り、elmではHTMLをHtml msgという型の値として扱います。viewHeader関数によって生成されるHTMLは次のようになります。

    <header>
      <div class="header-content">
        <div class="title">
          <a href="/index">Home</a>
        </div>
        <div class="about">
          <a href="/about">About</a>
        </div>
      </div>
    </header>

MicroCMSから記事を取得

https://github.com/yomek33/elm-blog/blob/main/app/Microcms.elm
elm-pagesではBackendTaskを使って非同期処理を定義し、実行します。ビルド時に必要なデータを取得し、コンパイルされた静的なHTMLを生成することができます。

取得する記事 Postの型を定義

type alias Post =
    { id : String
    , title : String
    , content : Maybe String
    , externalUrl : Maybe String
    , categories : List Category
    , publishedAt : String
    , slug : String
    }

contentexternalUrlは存在しない場合があるので、Maybe Stringにします。

環境変数を取得する

type alias Env =
    { serviceDomain : String
    , apiKey : String
    }


envTask : BackendTask FatalError Env
envTask =
    BackendTask.map2 Env
        (BackendTask.Env.expect "SERVICE_DOMAIN")
        (BackendTask.Env.expect "MICROCMS_API_KEY")
        |> BackendTask.allowFatal


microCmsURI : Env -> String
microCmsURI { serviceDomain } =
    "https://" ++ serviceDomain ++ "/api/v1"

BackendTask.Envを使って環境変数を取得します。
https://package.elm-lang.org/packages/dillonkearns/elm-pages-v3-beta/latest/BackendTask-Env

HTTPリクエスト

httpGet : Env -> String -> Decoder a -> BackendTask FatalError a
httpGet env path decoder =
    let
        url =
            microCmsURI env ++ path
    in
    BackendTask.Http.getWithOptions
        { url = url
        , expect = BackendTask.Http.expectJson decoder
        , headers = [ ( "X-MICROCMS-API-KEY", env.apiKey ) ]
        , cacheStrategy = Just BackendTask.Http.IgnoreCache
        , retries = Nothing
        , timeoutInMs = Nothing
        , cachePath = Nothing
        }
    |> BackendTask.allowFatal

getPosts : Env -> BackendTask FatalError (List Post)
getPosts env =
    httpGet env "/post?orders=-publishDate" postsDecoder

getPost : Env -> String -> BackendTask FatalError Post
getPost env slug =
    httpGet env ("/post?filters=slug[equals]" ++ slug)
        (Decode.field "contents" (Decode.index 0 postDecoder))

httpGet
リクエストヘッダーにX-MICROCMS-API-KEYを設定してリクエストします
https://package.elm-lang.org/packages/dillonkearns/elm-pages-v3-beta/latest/BackendTask-Http
共通の HTTP GET 処理を関数化しています。
ヘッダーに API キーを付け、JSON レスポンスを Elm の型へデコードするところまで一気に実行します。
BackendTask.allowFatal を付けることで、失敗時にはビルドを止めるエラーとして扱えます。
expectJson decoder を指定することで、取得したJSONデータを指定の Decoder を使ってElmのデータ型に変換します。

JSONデコーダー

postsDecoder : Decoder (List Post)
postsDecoder =
    Decode.field "contents" (Decode.list postDecoder)

postDecoder : Decoder Post
postDecoder =
    Decode.map7 Post
        (Decode.field "id" Decode.string)
        (Decode.field "title" Decode.string)
        (Decode.field "content" (Decode.nullable Decode.string))
        (Decode.oneOf
            [ Decode.field "externalUrl" (Decode.nullable Decode.string)
            , Decode.succeed Nothing
            ]
        )
        (Decode.field "categories" (Decode.list categoryDecoder))
        (Decode.field "publishedAt" Decode.string)
        (Decode.field "slug" Decode.string)

JSONレスポンスの形式に合わせて、各フィールドを抽出し、Elmの型である Post にマッピングします。

ブログ記事を表示する

app/Route/Blog/Slug_.elmでslugに対応する記事を表示させます。

data

fetchPost : RouteParams -> BackendTask FatalError Microcms.Post
fetchPost params =
    Microcms.envTask
        |> BackendTask.andThen (\env -> Microcms.getPost env params.slug)

Microcms.envTaskで環境変数を取得し、getPostparams.slug に一致する投稿を取得します。

data : RouteParams -> BackendTask FatalError Data
data routeParams =
    fetchPost routeParams
        |> BackendTask.map (\post -> { post = post })       

URL から渡される routeParams(ここでは { slug : String })を受け取り、記事データを非同期に取得して、ルートが期待する形の Data 型にします。これにより、ビュー側で app.data.post としてアクセス可能になります。

route

route : StatelessRoute RouteParams Data ActionData
route =
    RouteBuilder.preRender
        { head = head
        , data = \routeParams ->
            fetchPost routeParams
                |> BackendTask.map (\post -> { post = post })
        , pages = 
            Microcms.envTask
                |> BackendTask.andThen
                    (\env -> 
                        Microcms.getPosts env
                            |> BackendTask.map
                                (\posts ->
                                    List.map (\post -> { slug = post.slug }) posts
                                )
                    )
        }
        |> RouteBuilder.buildNoState { view = view }

ブログ記事ページをあらかじめビルドしておくためのルート定義です。preRenderを使うことで、ビルド時にすべてのページを静的 HTML として生成します。

RouteBuilder.preRender
https://package.elm-lang.org/packages/dillonkearns/elm-pages-v3-beta/latest/Scaffold-Route#preRender

view

view : App Data ActionData RouteParams -> Shared.Model -> View (PagesMsg Msg)
view app _ =
    { title = "yomek33"
    , body =
        [ div [class "article"]
            [ h1 [] [ text app.data.post.title ]
            , small  [] [text ( HtmlParser.trimDate app.data.post.publishedAt)
            , text (Maybe.withDefault "" (List.head app.data.post.categories |> Maybe.map (.name >> (++) "#")))]
            , case app.data.post.content of
                Just content ->
                   HtmlParser.parseHtml content
                Nothing ->
                    text ""
            ]
        ]
    }

dataで取得した情報を受け取って HTML を生成します。

エラーについて

elmでは、エラーをデータとして扱います。これはElmが保証してくれる安全性の一つで、ランタイムエラーでアプリケーション全体がクラッシュすることは基本的になく、その代わり失敗の可能性をカスタム型で表現します。
エラーハンドリング · An Introduction to Elmhttps://guide.elm-lang.jp/error_handling/

elm-pagesではFatalErrorという型を使ってエラーを扱います。
https://package.elm-lang.org/packages/dillonkearns/elm-pages-v3-beta/latest/FatalError

まとめ

簡単にまとめました。関数型やThe Elm Architectureを理解しているとは言い難いので、また機会があればまた使って解像度を上げたいです。

Discussion