こんにちは、@koga1020です。最近おサボりしてましたが、ちょいと時間ができたのでブログを書いてみます。
連休初日を使って、このブログをNimblePublisherを利用して動かすように改修しました♪
DBを利用せずに、
- 記事一覧・詳細
- カテゴリによる絞り込み
- 検索
- ページネーション
を動かしています。非常にサクサクと動いてくれています。
NimblePublisherはDashbit社が公開しているOSSで、同社のブログで利用されています。
背景やメリットなどは「Welcome to our blog: how it was made!」というブログ記事にまとまっています。こちらを読むのが一番早いと思います。
簡単に特徴を説明すると、
- compile時にmarkdownファイルを読み込み、構造体に変換
-
構造体に変換するタイミングでmarkdownをHTMLへ変換する
-
内部で
Earmark.as_html!/2
を呼ぶ - lib/nimble_publisher.ex#L44 あたり
-
内部で
- 構造体のリストをmodule attributeに格納
-
一覧、詳細、検索はmodule attributesにセットした構造体のリスト操作で実現する
-
Enum.filter
やEnum.find
を使い、単純なリスト操作で実現する
-
という具合に、markdownで記述したコンテンツをDBや静的ファイルでもなく、Elixirコード内に突っ込んでしまうというアプローチです。
上記ブログより:
..(中略) Dashbit.Blog.list_posts() returns a list of blog posts that have been precompiled and already loaded into memory. There is no database involved. In a nutshell, when our project compiles, we read all blog posts from disk and convert them into in-memory data structures.
静的サイトジェネレーターと、WordpressのようなDBを利用したCMSの中間のような、そんな印象です。
Markdown→module attributesへの変換を、次のように書くだけであっさり行ってくれます。
use NimblePublisher,
# 記事データを持たせるstructを指定
build: Article,
# mdファイルがあるpathを指定
from: "articles/**/*.md",
# module attributes名を指定
as: :articles,
# highlightersを指定。highlight.jsやprism.jsなど、別のツールを利用する場合はなくても良い
highlighters: [:makeup_elixir, :makeup_erlang]
作成日やtagの情報を記事に追加する場合は、mdファイルの先頭にmap形式で設定を記述します。例えば、この記事では次のように記述をしています。
# /posts/use-nimble-publisher.md
%{
title: "NimblePublisherを利用してDBなしのシンプルなブログシステムを構築する",
categories: ~w(Elixir),
publish_at: ~N[2020-09-19 00:00:00]
}
---
こんにちは、[@koga1020](https://twitter.com/koga1020_)です。...
buildに指定したモジュールにbuild/3
関数を生やすことで、その関数を利用して構造体への変換が行われます。このブログではmarkdownのファイル名をそのままurlとして詳細画面のkeyとしたかったので、filenameからurlのフィールドを作成し、構造体に持たせています。
defmodule Portfolio.Blog.Post do
@enforce_keys [:title, :body, :publish_at, :categories, :url]
defstruct [:title, :body, :publish_at, :categories, :url]
def build(filename, attrs, body) do
url = Path.basename(filename, ".md")
struct!(__MODULE__, [url: url, body: body] ++ Map.to_list(attrs))
end
end
最終的に、一覧、詳細、検索を記述したContextモジュールはこんな感じになりました。"lang-"
のprefixを追加しているのはprism.jsを利用してシンタックスハイライトを行なっているからです。
※ earmark_optionsの指定は2020/09/1時点での最新のv0.1.1には含まれておらず、最新のmasterには存在する指定です。depsで最新版を取得していれば利用できました。じきにリリースされるんじゃないでしょうか。
commit: Allow passing in Earmark options (#4)
defmodule Portfolio.Blog do
@moduledoc """
The Blog context.
"""
alias Portfolio.Blog.Post
alias Paginator
use NimblePublisher,
build: Post,
from: "posts/*.md",
as: :posts,
highlighters: [],
earmark_options: %Earmark.Options{
code_class_prefix: "lang-",
smartypants: false
}
@posts Enum.sort_by(@posts, & &1.publish_at, {:desc, NaiveDateTime})
def all_posts(), do: @posts
def list_posts(paginate_opts) do
Paginator.paginate(all_posts, paginate_opts)
end
def get_post_by_url(url) do
all_posts()
|> Enum.filter(&(&1.url == url))
|> case do
[post] -> post
_ -> nil
end
end
def search_posts(word, paginate_opts) do
all_posts()
|> Enum.filter(fn %{title: title, body: body} ->
String.contains?(title, word) or String.contains?(body, word)
end)
|> Paginator.paginate(paginate_opts)
end
def get_posts_by_category(category_name, paginate_opts) do
all_posts()
|> Enum.filter(fn %{categories: categories} ->
Enum.member?(categories, category_name)
end)
|> Paginator.paginate(paginate_opts)
end
end
Paginatorは [page_number: 1, page_size: 10]
のように渡したらうまいこと情報を整理してEnum.sliceしてくれるだけの単純なモジュールです。
defmodule Paginator do
def paginate(list, options) do
current_page = Keyword.get(options, :page_number, 1)
page_size = Keyword.get(options, :page_size, 5)
%{
total: Enum.count(list),
entries: Enum.slice(list, index(current_page, page_size), page_size),
current_page: current_page,
page_size: page_size,
last_page: last_page(list, page_size)
}
end
defp index(current, page_size) do
page_size * (current - 1)
end
defp last_page(list, page_size) do
list
|> Enum.count()
|> Kernel./(page_size)
|> Float.ceil()
|> trunc()
end
end
あとはこのBlogコンテキストを利用すれば、他のControllerやViewはDBありの場合ととくに差はなく、実装が可能です。
何が嬉しいのか
dashbitのブログにあるように、phoenixに標準でついているlive_reloadの仕組みが利用できるので、実際のページを確認しつつ記事を書くことができるのは便利ですね。
live_reload: [
patterns: [
...,
~r"posts/*/.*(md)$"
]
]
また、DBがない分phoenixサーバーを置くだけで完結するので、あまりコストをかけたくない個人開発にも向いていそうです。
「ページネーションや検索などサーバーサイドでちょっとしたロジックは書きたいが、コンテンツ自体はmdファイルだけでGitHub管理したい」というニーズにぴったりでした。
まとめ
「mdファイルを書く→commit→gigalixirへpush」というシンプルな運用になってすっきりしました。ついでにhighlight.jsからprism.jsに乗り換えたのですが、今の所ちゃんと動いてくれてそうです。しっかりコンテンツの方も育てていきたいところです😇w