こんにちは、@koga1020です。最近おサボりしてましたが、ちょいと時間ができたのでブログを書いてみます。

連休初日を使って、このブログをNimblePublisherを利用して動かすように改修しました♪

DBを利用せずに、

  • 記事一覧・詳細
  • カテゴリによる絞り込み
  • 検索
  • ページネーション

を動かしています。非常にサクサクと動いてくれています。

NimblePublisherはDashbit社が公開しているOSSで、同社のブログで利用されています。

背景やメリットなどは「Welcome to our blog: how it was made!」というブログ記事にまとまっています。こちらを読むのが一番早いと思います。

簡単に特徴を説明すると、

  • compile時にmarkdownファイルを読み込み、構造体に変換
  • 構造体に変換するタイミングでmarkdownをHTMLへ変換する
  • 構造体のリストをmodule attributeに格納
  • 一覧、詳細、検索はmodule attributesにセットした構造体のリスト操作で実現する
    • Enum.filterEnum.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