NimblePublisher

NimblePublisherは、Markdownをサポートし、コードハイライトを備えた、最小のファイルシステムベースの出版エンジンです。

なぜNimblePublisherを利用するのか

NimblePublisherは、Markdown構文のローカルファイルからパースしたコンテンツを公開するために設計されたシンプルなライブラリです。典型的な使用例としては、ブログの構築が挙げられます。

このライブラリは、Dashbit社が自社のブログに使用しているコードのほとんどをカプセル化しています。Dashbit社のポストWelcome to our blog: how it was made!で紹介されており、データベースや複雑なCMSを使用する代わりに、ローカルファイルからコンテンツをパースすることを選択した理由が説明されています。

コンテンツを作成する

自身のブログを作ってみましょう。この例ではPhoenixアプリケーションを使用していますが、Phoenixは必須ではありません。NimblePublisherはローカルファイルのパースのみ行うので、どんなElixirアプリケーションでも使用できます。

まず、新しいPhoenixアプリケーションを作ってみましょう。名前をNimbleSchoolとし、Ectoを必要としないため、次のように作成します。

mix phx.new nimble_school --no-ecto

それでは、投稿を追加してみましょう。まず、投稿を格納するディレクトリを作成する必要があります。このような形式で年ごとに管理します。

/priv/posts/YEAR/MONTH-DAY-ID.md

たとえば、これら2つの投稿から始めてみます。

/priv/posts/2020/10-28-hello-world.md
/priv/posts/2020/11-04-exciting-news.md

典型的なブログ投稿はMarkdownの構文で書かれており、トップにはメタデータのセクションがあり、その下にはコンテンツが --- で区切られています。

%{
  title: "Hello World!",
  author: "Jaime Iniesta",
  tags: ~w(hello),
  description: "Our first blog post is here"
}
---
Yes, this is **the post** you've been waiting for.

あとは創造的に自分の投稿を書くことができます。ただ、メタデータとコンテンツのフォーマットを守ってください。

これらの投稿ができたら、NimblePublisherをインストールして、コンテンツをパースし、 Blog コンテキストを構築しましょう。

NimblePublisherをインストールする

まず、 nimble_publisher を依存関係として追加します。任意でシンタックスハイライターを含めることができます。ここでは、ElixirとErlangのコードハイライトをサポートするライブラリを追加します。

Phoenixアプリでは、 mix.exs にこれを追加します。

  defp deps do
    [
      ...,
      {:nimble_publisher, "~> 0.1.1"},
      {:makeup_elixir, ">= 0.0.0"},
      {:makeup_erlang, ">= 0.0.0"}
    ]
  end

mix deps.get を実行して依存関係を取得したら、ブログの構築を続ける準備が整いました。

Blogコンテキストを構築する

ここでは、ファイルからパースされたコンテンツを格納する Post 構造体を定義します。この構造体には、各メタデータのキーと、ファイル名からパースされる :date が必要です。次のように lib/nimble_school/blog/post.ex ファイルを作成します。

defmodule NimbleSchool.Blog.Post do
  @enforce_keys [:id, :author, :title, :body, :description, :tags, :date]
  defstruct [:id, :author, :title, :body, :description, :tags, :date]

  def build(filename, attrs, body) do
    [year, month_day_id] = filename |> Path.rootname() |> Path.split() |> Enum.take(-2)
    [month, day, id] = String.split(month_day_id, "-", parts: 3)
    date = Date.from_iso8601!("#{year}-#{month}-#{day}")
    struct!(__MODULE__, [id: id, date: date, body: body] ++ Map.to_list(attrs))
  end
end

Post モジュールは、メタデータとコンテンツの構造体を定義し、投稿のコンテンツを含むファイルをパースするのに必要なロジックを持つ build/3 関数も定義します。

この Post 構造体をもとに、NimblePublisherを使ってローカルファイルをパースして投稿にする Blog コンテキストを定義できます。次のように lib/nimble_school/blog/blog.ex を作成します。

defmodule NimbleSchool.Blog do
  alias NimbleSchool.Blog.Post

  use NimblePublisher,
    build: Post,
    from: Application.app_dir(:nimble_school, "priv/posts/**/*.md"),
    as: :posts,
    highlighters: [:makeup_elixir, :makeup_erlang]

  # The @posts variable is first defined by NimblePublisher.
  # Let's further modify it by sorting all posts by descending date.
  @posts Enum.sort_by(@posts, & &1.date, {:desc, Date})

  # Let's also get all tags
  @tags @posts |> Enum.flat_map(& &1.tags) |> Enum.uniq() |> Enum.sort()

  # And finally export them
  def all_posts, do: @posts
  def all_tags, do: @tags
end

ご覧のとおり、 Blog コンテキストでは、NimblePublisherを使って、指定したローカルディレクトリから、使いたいシンタックスハイライターを使って、 Post のコレクションを構築しています。

NimblePublisherは @posts という変数を作成し、あとでこれを処理して、 :date 降順に記事をソートします。これはブログで通常必要とされます。

また、 @tags@posts から取得して定義します。

最後に、 all_posts/0all_tags/0 を定義して、それぞれパースされたものを返すようにしています。

では早速やってみましょう。コンソールで iex -S mix と入力して実行してみてください。

iex(1)> NimbleSchool.Blog.all_posts()
[
  %NimbleSchool.Blog.Post{
    author: "Jaime Iniesta",
    body: "<p>\nAwesome, this is our second post in our great blog.</p>\n",
    date: ~D[2020-11-04],
    description: "Second blog post",
    id: "exciting-news",
    tags: ["exciting", "news"],
    title: "Exciting News!"
  },
  %NimbleSchool.Blog.Post{
    author: "Jaime Iniesta",
    body: "<p>\nYes, this is <strong>the post</strong> you’ve been waiting for.</p>\n",
    date: ~D[2020-10-28],
    description: "Our first blog post is here",
    id: "hello-world",
    tags: ["hello"],
    title: "Hello World!"
  }
]

iex(2)> NimbleSchool.Blog.all_tags()
["exciting", "hello", "news"]

素晴らしいと思いませんか?すでにすべての投稿がMarkdownの解釈をもとにパースされ、準備が整っています。タグも同様です!

ここで重要なのは、NimblePublisherがファイルをパースして、それらすべてを含む @posts 変数を構築していることで、あなたはそこから必要な関数を定義します。たとえば、最近の投稿を取得する関数が必要な場合は、次のように定義します。

def recent_posts(num \\ 5), do: Enum.take(all_posts(), num)

ご覧のように、新しい関数の中では @posts を使わず、代わりに all_posts() を使っています。そうしないと、 @posts 変数がコンパイラによって2回展開され、すべての投稿の完全なコピーが作成されてしまうからです。

完全なブログの例を作るために、さらにいくつかの関数を定義してみましょう。idでポストを取得したり、指定したタグのポストをすべてリストアップする必要があります。以下の関数を Blog コンテキストの中で定義します。

defmodule NotFoundError, do: defexception [:message, plug_status: 404]

def get_post_by_id!(id) do
  Enum.find(all_posts(), &(&1.id == id)) ||
    raise NotFoundError, "post with id=#{id} not found"
end

def get_posts_by_tag!(tag) do
  case Enum.filter(all_posts(), &(tag in &1.tags)) do
    [] -> raise NotFoundError, "posts with tag=#{tag} not found"
    posts -> posts
  end
end

コンテンツを提供する

すべての投稿とタグを取得する方法ができたので、あとはルート、コントローラー、ビュー、テンプレートを通常の方法で繋ぐだけです。この例では、シンプルにすべての投稿をリストアップし、IDで投稿を取得することにします。タグで投稿を一覧表示したり、最近の投稿でレイアウトを拡張したりすることは、読者の課題とします。

ルート

lib/nimble_school_web/router.ex に次のようにルートを定義します。

scope "/", NimbleSchoolWeb do
  pipe_through :browser

  get "/blog", BlogController, :index
  get "/blog/:id", BlogController, :show
end

コントローラー

投稿を提供するために、 lib/nimble_school_web/controllers/blog_controller.ex にコントローラーを定義します。

defmodule NimbleSchoolWeb.BlogController do
  use NimbleSchoolWeb, :controller

  alias NimbleSchool.Blog

  def index(conn, _params) do
    render(conn, "index.html", posts: Blog.all_posts())
  end

  def show(conn, %{"id" => id}) do
    render(conn, "show.html", post: Blog.get_post_by_id!(id))
  end
end

ビュー

ビューモジュールを作成して、ビューのレンダリングに必要なヘルパーを配置します。今回は次のようにします。

defmodule NimbleSchoolWeb.BlogView do
  use NimbleSchoolWeb, :view
end

テンプレート

最後に、コンテンツをレンダリングするためのHTMLファイルを作成します。投稿の一覧をレンダリングするために、 lib/nimble_school_web/templates/blog/index.html.eex を次のように定義します。

<h1>Listing all posts</h1>

<%= for post <- @posts do %>
  <div id="<%= post.id %>" style="margin-bottom: 3rem;">
    <h2>
      <%= link post.title, to: Routes.blog_path(@conn, :show, post)%>
    </h2>

    <p>
      <time><%= post.date %></time> by <%= post.author %>
    </p>

    <p>
      Tagged as <%= Enum.join(post.tags, ", ") %>
    </p>

    <%= raw post.description %>
  </div>
<% end %>

そして、単一の投稿をレンダリングするために、 lib/nimble_school_web/templates/blog/show.html.eex を作成します。

<%= link "← All posts", to: Routes.blog_path(@conn, :index)%>

<h1><%= @post.title %></h1>

<p>
  <time><%= @post.date %></time> by <%= @post.author %>
</p>

<p>
  Tagged as <%= Enum.join(@post.tags, ", ") %>
</p>

<%= raw @post.body %>

投稿を閲覧する!

これで準備は整いました!

iex -S mix phx.server でウェブサーバーを起動し、http://localhost:4000/blogにアクセスして、新しいブログが実際に動いているのを見てみましょう!

メタデータを拡張する

NimblePublisherは、投稿の構造やメタデータの定義に関して非常に柔軟です。たとえば、 :published キーを追加して投稿にフラグを立て、それが true であるものだけを表示したいとします。

そのためには、 :published キーを Post 構造体に追加し、投稿のメタデータにも追加する必要があります。 Blog モジュールでは、次のように定義します。

def all_posts, do: @posts

def published_posts, do: Enum.filter(all_posts(), &(&1.published == true))

def recent_posts(num \\ 5), do: Enum.take(published_posts(), num)

シンタックスハイライト

NimblePublisherでは、シンタックスハイライトにMakeupライブラリを使用しています。こちらで定義されているものから、好みのスタイルのCSSクラスを生成する必要があります。

たとえば、ここでは :tango_style というスタイルを使います。 iex -S mix のセッションから、次のように呼び出します。

Makeup.stylesheet(:tango_style, "makeup") |> IO.puts()

そして、生成されたCSSクラスをスタイルシートに配置してください。

他のコンテンツを提供する

NimblePublisherは、異なる構造を持つ他のコンテキストの出版にも使用できます。

たとえば、よくある質問(FAQ)を集めて管理したいとします。この場合、日付や著者は必要なく、 :id:question:answer のシンプルな構造が適しています。

また、コンテンツファイルを別のディレクトリ構造に配置することもできます。たとえば、次の通りです。

/priv/faqs/is-there-a-free-trial.md
/priv/faqs/when-did-it-start.md

そして、 lib/nimble_school/faqs/faq.exFaq 構造体とbuild関数を次のように定義します。

defmodule NimbleSchool.Faqs.Faq do
  @enforce_keys [:id, :question, :answer]
  defstruct [:id, :question, :answer]

  def build(filename, attrs, body) do
    [id] = filename |> Path.rootname() |> Path.split() |> Enum.take(-1)
    struct!(__MODULE__, [id: id, answer: body] ++ Map.to_list(attrs))
  end
end

lib/nimble_school/faqs/faqs.exFaqs コンテキストは次のようになります。

defmodule NimbleSchool.Faqs do
  alias NimbleSchool.Faqs.Faq

  use NimblePublisher,
    build: Faq,
    from: Application.app_dir(:nimble_school, "priv/faqs/*.md"),
    as: :faqs

  # The @faqs variable is first defined by NimblePublisher.
  # Let's further modify it by sorting all posts by ascending question
  @faqs Enum.sort_by(@faqs, & &1.question)

  # And finally export them
  def all_faqs, do: @faqs
end

サンプルのソースコード

このサンプルのコードはhttps://github.com/jaimeiniesta/nimble_schoolに掲載されています。

Caught a mistake or want to contribute to the lesson? Edit this lesson on GitHub!