We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
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