Published on

Welcome to my blog

Why go through the effort of building my own blog and not just use ghost?

If you had asked me a few weeks ago I would have agreed that it sounds like a waste of time. I’ve tried so many variations of blogs. From early days with WordPress, to static website generators like jekyll and hexo, to hosted versions like medium. All have their strengths and weaknesses, but I have never found one that suited me perfectly.

Then I read José Valim’s Dashbit blog post on how they rolled their own blog. It appeared there was an effortless way to set up a blog in a Phoenix app with NimblePublisher!

I would have the best of both worlds. It’s kinda static with no database, it’s kinda a publishing system with Phoenix. And it’s all Elixir which is what I do all day. If one day I no longer work with Elixir professionally I would still have a good excuse to keep up with Phoenix development 😄

So that’s what I did over the weekend. Below I’ll detail how I set it up in Phoenix 1.7 using LiveView.

I also recommend reading Elixir School’s lesson and David Bernheisel’s blog post.


First, we’ll generate a new Phoenix app:

mix phx.new my_blog --no-ecto

Add NimblePublisher to the dependencies:

# mix.exs
defmodule MyBlog.MixProject do
  use Mix.Project

  # ...

  def deps do
    [
      # ...
      {:nimble_publisher, "~> 0.1.1"}
    ]
  end

  #...
end

Add the Blog context and Blog.Post module:

# lib/my_blog/blog.ex
defmodule MyBlog.Blog do
  alias MyApp.Blog.Post

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

  @posts Enum.sort_by(@posts, & &1.date, {:desc, Date})

  def list_posts, do: @posts

  @tags @posts |> Enum.flat_map(& &1.tags) |> Enum.uniq() |> Enum.sort()

  def list_tags, do: @tags

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

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

  def list_posts_by_tag!(tag) do
    case Enum.filter(list_posts(), &(tag in &1.tags)) do
      [] -> raise NotFoundError, "posts with tag=#{tag} not found"
      posts -> posts
    end
  end
end
# lib/my_blog/blog/post.ex
defmodule MyBlog.Blog.Post do
  @enforce_keys [:id, :title, :body, :description, :tags, :date]
  defstruct [:id, :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

Create a new blog post:

# priv/posts/2023/01-01-hello-world.md
%{
  title: "Hello world",
  tags: ~w(hello),
  description: "This is my first blog post"
}
---
Hello world!

Set up the routes:

# lib/my_blog_web/router.ex
defmodule MyBlogWeb.Router do
  use MyBlogWeb, :router

  # ...

  scope "/", MyBlogWeb do
    pipe_through :browser

    live "/", BlogLive, :index
    live "/tags/:tag", BlogLive, :index
    live "/posts/:slug", BlogLive, :show
  end

  # ...
end

Set up the BlogLive module and the templates:

# lib/my_blog_web/live/blog_live.ex
defmodule MyBlogWeb.BlogLive do
  use MyBlogWeb, :live_view

  embed_templates "blog_live/*"

  @impl true
  def mount(_params, _session, socket) do
    {:ok, socket}
  end

  @impl true
  def handle_params(params, _url, socket) do
    {:noreply, apply_action(socket, socket.assigns.live_action, params)}
  end

  defp apply_action(socket, :index, %{"tag" => tag}) do
    socket
    |> assign(:page_title, tag)
    |> assign(:posts, MyBlog.Blog.list_posts_by_tag!(tag))
    |> assign(:tag, tag)
  end

  defp apply_action(socket, :index, _params) do
    assign(socket, :posts, MyBlog.Blog.list_posts())
  end

  defp apply_action(socket, :show, %{"slug" => slug}) do
    post = MyBlog.Blog.get_post_by_id!(slug)

    socket
    |> assign(:page_title, post.title)
    |> assign(:post, post)
  end

  @impl true
  def render(assigns) do
    apply(__MODULE__, assigns.live_action, [assigns])
  end
end
# lib/my_blog_web/live/blog_live/index.html.eex
<ul>
  <li :for={post <- @posts} class="py-4">
    <article class="space-y-2 xl:grid xl:grid-cols-4 xl:items-baseline xl:space-y-0">
      <dl>
        <dt class="sr-only">Published on</dt>
        <dd class="text-base font-medium leading-6 text-gray-500">
          <time datetime={post.date}><%= post.date %></time>
        </dd>
      </dl>
      <div class="space-y-3 xl:col-span-3">
        <div>
          <h3 class="text-2xl font-bold leading-8 tracking-tight">
            <.link navigate={~p"/posts/#{post.id}"} class="text-gray-900">
              <%= post.title %>
            </.link>
          </h3>
          <ul class="flex flex-wrap">
            <li :for={tag <- post.tags} class="mr-3">
              <.link navigate={~p"/tags/#{tag}"} class="text-sm font-light uppercase text-black/50">
                <%= tag %>
              </.link>
            </li>
          </ul>
        </div>
        <div class="prose max-w-none text-black/50">
          <%= post.description %>
        </div>
      </div>
    </article>
  </li>
</ul>
# lib/my_blog/live/blog_live/show.html.eex
<div class="mx-auto px-4 max-w-4xl">
  <div class="py-5 text-center">
    <p class="text-sm">
      <dt class="sr-only">Published on</dt>
      <dd class="text-base font-medium leading-6 text-white/50">
        <time datetime={@post.date}><%= @post.date %></time>
      </dd>
    </p>
    <h1 class="text-5xl font-extrabold"><%= @post.title %></h1>
  </div>

  <div class="prose py-8 max-w-4xl text-lg">
    <%= raw @post.body %>
  </div>
</div>

Finally, update live reload to improve the dev experience (otherwise you have to restart the server constantly):

config :my_blog, MyBlogWeb.Endpoint,
  live_reload: [
    patterns: [
      # ...
      ~r"posts/*/.*(md)$"
    ]
  ]

Now run mix deps.get, mix phx.server, and visit http://localhost:4000/.

That was easy!

The only thing you are missing is to style it which seems to always be what takes the longest when starting a new blog 😅

Hi, I'm Dan Schultzer, I write in this blog, work a lot in Elixir, maintain several open source projects, and help companies streamline their development process