elm-pagesでシンプルな自作ブログサイトを作った
elm-pages, MicroCMS, Vercelを使ってJAMstackなブログサイトを作ってみました。
Elmについて
ElmはJavaScript にコンパイルできる関数型プログラミング言語です。フロントエンドの実装のしやすさや型安全性などの文脈でよく耳にしていたので、ブログ作成に使ってみました。
私自身はこれまでElmを書いたことはありません。他の関数型言語はちょっと書いたことがありました。
学習に役立ったサイト、動画
Elm-pages v3とは
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
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から記事を取得
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
}
content
とexternalUrl
は存在しない場合があるので、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を使って環境変数を取得します。
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
を設定してリクエストします
共通の 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
で環境変数を取得し、getPost
でparams.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
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
という型を使ってエラーを扱います。
まとめ
簡単にまとめました。関数型やThe Elm Architectureを理解しているとは言い難いので、また機会があればまた使って解像度を上げたいです。
Discussion