Static blog with front page summary

Goal: have a personal blog, and try out another point in the 'modular
app design with elixir' space.

Designing OTP systems with elixir had some interesting ideas.
This commit is contained in:
Your Name
2026-03-17 11:17:21 +00:00
commit bc14696f57
94 changed files with 6846 additions and 0 deletions
+122
View File
@@ -0,0 +1,122 @@
defmodule Blogex.Blog do
@moduledoc """
Macro to define a blog context backed by NimblePublisher.
## Usage
In your host application, define one module per blog:
defmodule Firehose.EngineeringBlog do
use Blogex.Blog,
blog_id: :engineering,
app: :firehose,
from: "priv/blog/engineering/**/*.md",
title: "Engineering Blog",
description: "Deep dives into our tech stack",
base_path: "/blog/engineering"
end
defmodule Firehose.ReleaseNotes do
use Blogex.Blog,
blog_id: :release_notes,
app: :firehose,
from: "priv/blog/release-notes/**/*.md",
title: "Release Notes",
description: "What's new in Firehose",
base_path: "/blog/releases"
end
Each module compiles all markdown posts at build time and exposes
query functions like `all_posts/0`, `get_post!/1`, `posts_by_tag/1`, etc.
"""
defmacro __using__(opts) do
blog_id = Keyword.fetch!(opts, :blog_id)
app = Keyword.fetch!(opts, :app)
from = Keyword.fetch!(opts, :from)
title = Keyword.fetch!(opts, :title)
description = Keyword.get(opts, :description, "")
base_path = Keyword.fetch!(opts, :base_path)
highlighters = Keyword.get(opts, :highlighters, [:makeup_elixir, :makeup_erlang])
quote do
alias Blogex.Post
use NimblePublisher,
build: Post,
from: Application.app_dir(unquote(app), unquote(from)),
as: :posts,
highlighters: unquote(highlighters)
# Inject the blog_id into each post and sort by descending date
@posts @posts
|> Enum.map(&Map.put(&1, :blog, unquote(blog_id)))
|> Enum.sort_by(& &1.date, {:desc, Date})
# Collect all unique tags
@tags @posts |> Enum.flat_map(& &1.tags) |> Enum.uniq() |> Enum.sort()
@blog_id unquote(blog_id)
@blog_title unquote(title)
@blog_description unquote(description)
@blog_base_path unquote(base_path)
@doc "Returns the blog identifier atom."
def blog_id, do: @blog_id
@doc "Returns the blog title."
def title, do: @blog_title
@doc "Returns the blog description."
def description, do: @blog_description
@doc "Returns the base URL path for this blog."
def base_path, do: @blog_base_path
@doc "Returns all published posts, newest first."
def all_posts, do: Enum.filter(@posts, & &1.published)
@doc "Returns the N most recent published posts."
def recent_posts(n \\ 5), do: Enum.take(all_posts(), n)
@doc "Returns all unique tags across all published posts."
def all_tags, do: @tags
@doc "Returns all published posts matching the given tag."
def posts_by_tag(tag) do
Enum.filter(all_posts(), fn post -> tag in post.tags end)
end
@doc "Returns a single post by slug/id, or raises."
def get_post!(id) do
Enum.find(all_posts(), &(&1.id == id)) ||
raise Blogex.NotFoundError, "post #{inspect(id)} not found in #{@blog_id}"
end
@doc "Returns a single post by slug/id, or nil."
def get_post(id) do
Enum.find(all_posts(), &(&1.id == id))
end
@doc "Returns paginated posts. Page is 1-indexed."
def paginate(page \\ 1, per_page \\ 10) do
posts = all_posts()
total = length(posts)
total_pages = max(ceil(total / per_page), 1)
entries =
posts
|> Enum.drop((page - 1) * per_page)
|> Enum.take(per_page)
%{
entries: entries,
page: page,
per_page: per_page,
total_entries: total,
total_pages: total_pages
}
end
end
end
end
+153
View File
@@ -0,0 +1,153 @@
defmodule Blogex.Components do
@moduledoc """
Phoenix function components for rendering blog content.
These are unstyled building blocks — the host app wraps them
in its own layout and applies its own CSS.
## Usage in a LiveView or template:
import Blogex.Components
<.post_index blog={@blog_module} posts={@posts} />
<.post_show post={@post} />
<.tag_list tags={@tags} base_path={@base_path} />
"""
use Phoenix.Component
@doc """
Renders a list of post previews.
## Attributes
* `:posts` - list of `%Blogex.Post{}` structs (required)
* `:base_path` - base URL path for post links (required)
"""
attr :posts, :list, required: true
attr :base_path, :string, required: true
def post_index(assigns) do
~H"""
<div class="blogex-post-index">
<article :for={post <- @posts} class="blogex-post-preview">
<header>
<h2>
<a href={"#{@base_path}/#{post.id}"}>{post.title}</a>
</h2>
<.post_meta post={post} />
</header>
<p class="blogex-post-description">{post.description}</p>
</article>
</div>
"""
end
@doc """
Renders a full blog post.
## Attributes
* `:post` - a `%Blogex.Post{}` struct (required)
"""
attr :post, :map, required: true
def post_show(assigns) do
~H"""
<article class="blogex-post">
<header class="blogex-post-header">
<h1>{@post.title}</h1>
<.post_meta post={@post} />
</header>
<div class="blogex-post-body">
{Phoenix.HTML.raw(@post.body)}
</div>
</article>
"""
end
@doc """
Renders post metadata (date, author, tags).
"""
attr :post, :map, required: true
def post_meta(assigns) do
~H"""
<div class="blogex-post-meta">
<time datetime={Date.to_iso8601(@post.date)}>
{Calendar.strftime(@post.date, "%B %d, %Y")}
</time>
<span :if={@post.author} class="blogex-post-author">
by {@post.author}
</span>
<span :for={tag <- @post.tags} class="blogex-tag">
{tag}
</span>
</div>
"""
end
@doc """
Renders a tag cloud / tag list with links.
## Attributes
* `:tags` - list of tag strings (required)
* `:base_path` - base URL path (required)
* `:current_tag` - currently selected tag for highlighting (optional)
"""
attr :tags, :list, required: true
attr :base_path, :string, required: true
attr :current_tag, :string, default: nil
def tag_list(assigns) do
~H"""
<nav class="blogex-tag-list">
<a
:for={tag <- @tags}
href={"#{@base_path}/tag/#{tag}"}
class={["blogex-tag-link", tag == @current_tag && "blogex-tag-active"]}
>
{tag}
</a>
</nav>
"""
end
@doc """
Renders pagination controls.
## Attributes
* `:page` - current page number (required)
* `:total_pages` - total number of pages (required)
* `:base_path` - base URL path (required)
"""
attr :page, :integer, required: true
attr :total_pages, :integer, required: true
attr :base_path, :string, required: true
def pagination(assigns) do
~H"""
<nav :if={@total_pages > 1} class="blogex-pagination">
<a
:if={@page > 1}
href={"#{@base_path}?page=#{@page - 1}"}
class="blogex-pagination-prev"
>
&larr; Newer
</a>
<span class="blogex-pagination-info">
Page {@page} of {@total_pages}
</span>
<a
:if={@page < @total_pages}
href={"#{@base_path}?page=#{@page + 1}"}
class="blogex-pagination-next"
>
Older &rarr;
</a>
</nav>
"""
end
end
+139
View File
@@ -0,0 +1,139 @@
defmodule Blogex.Feed do
@moduledoc """
Generates RSS 2.0 and Atom feeds for a blog.
## Usage
# In a controller or plug:
xml = Blogex.Feed.rss(MyApp.EngineeringBlog, "https://myapp.com")
conn |> put_resp_content_type("application/rss+xml") |> send_resp(200, xml)
"""
@doc """
Generates an RSS 2.0 XML feed for the given blog module.
## Options
* `:limit` - max number of posts to include (default: 20)
* `:language` - feed language (default: "en-us")
"""
def rss(blog_module, base_url, opts \\ []) do
limit = Keyword.get(opts, :limit, 20)
language = Keyword.get(opts, :language, "en-us")
posts = Enum.take(blog_module.all_posts(), limit)
blog_url = "#{base_url}#{blog_module.base_path()}"
feed_url = "#{blog_url}/feed.xml"
pub_date =
case posts do
[latest | _] -> format_rfc822(latest.date)
[] -> format_rfc822(Date.utc_today())
end
"""
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<title>#{escape(blog_module.title())}</title>
<link>#{blog_url}</link>
<description>#{escape(blog_module.description())}</description>
<language>#{language}</language>
<pubDate>#{pub_date}</pubDate>
<atom:link href="#{feed_url}" rel="self" type="application/rss+xml"/>
#{Enum.map_join(posts, "\n", &item_xml(&1, base_url, blog_module))}
</channel>
</rss>
"""
|> String.trim()
end
@doc """
Generates an Atom feed for the given blog module.
## Options
* `:limit` - max number of posts to include (default: 20)
"""
def atom(blog_module, base_url, opts \\ []) do
limit = Keyword.get(opts, :limit, 20)
posts = Enum.take(blog_module.all_posts(), limit)
blog_url = "#{base_url}#{blog_module.base_path()}"
feed_url = "#{blog_url}/feed.xml"
updated =
case posts do
[latest | _] -> format_iso8601(latest.date)
[] -> format_iso8601(Date.utc_today())
end
"""
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>#{escape(blog_module.title())}</title>
<link href="#{blog_url}" rel="alternate"/>
<link href="#{feed_url}" rel="self"/>
<id>#{blog_url}</id>
<updated>#{updated}</updated>
#{Enum.map_join(posts, "\n", &entry_xml(&1, base_url, blog_module))}
</feed>
"""
|> String.trim()
end
# Private helpers
defp item_xml(post, base_url, blog_module) do
url = "#{base_url}#{blog_module.base_path()}/#{post.id}"
"""
<item>
<title>#{escape(post.title)}</title>
<link>#{url}</link>
<guid isPermaLink="true">#{url}</guid>
<pubDate>#{format_rfc822(post.date)}</pubDate>
<description>#{escape(post.description)}</description>
<content:encoded><![CDATA[#{post.body}]]></content:encoded>
#{Enum.map_join(post.tags, "\n", &" <category>#{escape(&1)}</category>")}
</item>
"""
end
defp entry_xml(post, base_url, blog_module) do
url = "#{base_url}#{blog_module.base_path()}/#{post.id}"
"""
<entry>
<title>#{escape(post.title)}</title>
<link href="#{url}" rel="alternate"/>
<id>#{url}</id>
<published>#{format_iso8601(post.date)}</published>
<updated>#{format_iso8601(post.date)}</updated>
<author><name>#{escape(post.author)}</name></author>
<summary>#{escape(post.description)}</summary>
<content type="html"><![CDATA[#{post.body}]]></content>
#{Enum.map_join(post.tags, "\n", &" <category term=\"#{escape(&1)}\"/>")}
</entry>
"""
end
defp format_rfc822(date) do
date
|> DateTime.new!(~T[00:00:00], "Etc/UTC")
|> Calendar.strftime("%a, %d %b %Y %H:%M:%S +0000")
end
defp format_iso8601(date) do
"#{Date.to_iso8601(date)}T00:00:00Z"
end
defp escape(text) when is_binary(text) do
text
|> String.replace("&", "&amp;")
|> String.replace("<", "&lt;")
|> String.replace(">", "&gt;")
|> String.replace("\"", "&quot;")
|> String.replace("'", "&apos;")
end
end
+91
View File
@@ -0,0 +1,91 @@
defmodule Blogex.Layout do
@moduledoc """
Minimal HTML layout for Blogex pages.
Provides a default HTML shell when serving blog content to browsers.
Host apps can override by providing their own layout module via the
`:layout` option on the router.
"""
use Phoenix.Component
import Blogex.Components
@doc """
Wraps blog content in a minimal HTML page.
"""
attr :title, :string, required: true
attr :inner_content, :any, required: true
def page(assigns) do
~H"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{@title}</title>
</head>
<body style="max-width: 48rem; margin: 0 auto; padding: 2rem; font-family: system-ui, sans-serif;">
{Phoenix.HTML.raw(@inner_content)}
</body>
</html>
"""
end
@doc "Renders the post index page."
def index_page(assigns) do
~H"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{@blog_title}</title>
</head>
<body style="max-width: 48rem; margin: 0 auto; padding: 2rem; font-family: system-ui, sans-serif;">
<h1>{@blog_title}</h1>
<p>{@blog_description}</p>
<.post_index posts={@posts} base_path={@base_path} />
<.pagination page={@page} total_pages={@total_pages} base_path={@base_path} />
</body>
</html>
"""
end
@doc "Renders a single post page."
def show_page(assigns) do
~H"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{@post.title}</title>
</head>
<body style="max-width: 48rem; margin: 0 auto; padding: 2rem; font-family: system-ui, sans-serif;">
<nav><a href={@base_path}>&larr; Back</a></nav>
<.post_show post={@post} />
</body>
</html>
"""
end
@doc "Renders a tag listing page."
def tag_page(assigns) do
~H"""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{@blog_title} — #{@tag}</title>
</head>
<body style="max-width: 48rem; margin: 0 auto; padding: 2rem; font-family: system-ui, sans-serif;">
<nav><a href={@base_path}>&larr; Back</a></nav>
<h1>Posts tagged "{@tag}"</h1>
<.post_index posts={@posts} base_path={@base_path} />
</body>
</html>
"""
end
end
+7
View File
@@ -0,0 +1,7 @@
defmodule Blogex.NotFoundError do
@moduledoc """
Raised when a blog or post is not found.
Implements Plug.Exception to return a 404 status.
"""
defexception [:message, plug_status: 404]
end
+68
View File
@@ -0,0 +1,68 @@
defmodule Blogex.Post do
@moduledoc """
Struct representing a single blog post.
Posts are parsed from markdown files with frontmatter metadata.
The filename determines the date and slug:
priv/blog/engineering/2026/03-10-our-new-architecture.md
Frontmatter example:
%{
title: "Our New Architecture",
author: "Jane Doe",
tags: ~w(elixir architecture),
description: "How we rebuilt our platform"
}
---
Your markdown content here...
"""
@enforce_keys [:id, :title, :author, :body, :description, :date]
defstruct [
:id,
:title,
:author,
:body,
:description,
:date,
:blog,
tags: [],
published: true
]
@type t :: %__MODULE__{
id: String.t(),
title: String.t(),
author: String.t(),
body: String.t(),
description: String.t(),
date: Date.t(),
tags: [String.t()],
blog: atom(),
published: boolean()
}
@doc """
Build callback for NimblePublisher.
Extracts the date from the filename path and merges with frontmatter attrs.
The `blog` atom is injected by the parent Blog module.
"""
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
+52
View File
@@ -0,0 +1,52 @@
defmodule Blogex.Registry do
@moduledoc """
Registry that tracks all blog modules in the host application.
Configure in your app's config:
config :blogex,
blogs: [MyApp.EngineeringBlog, MyApp.ReleaseNotes]
Then you can query across all blogs:
Blogex.Registry.all_posts() # posts from all blogs, sorted by date
Blogex.Registry.blogs() # list of blog modules
Blogex.Registry.get_blog!(:engineering) # get a specific blog module
"""
@doc "Returns the list of configured blog modules."
def blogs do
Application.get_env(:blogex, :blogs, [])
end
@doc "Returns a blog module by its blog_id, or raises."
def get_blog!(blog_id) do
Enum.find(blogs(), fn mod -> mod.blog_id() == blog_id end) ||
raise Blogex.NotFoundError, "blog #{inspect(blog_id)} not found"
end
@doc "Returns a blog module by its blog_id, or nil."
def get_blog(blog_id) do
Enum.find(blogs(), fn mod -> mod.blog_id() == blog_id end)
end
@doc "Returns all posts from all blogs, sorted newest first."
def all_posts do
blogs()
|> Enum.flat_map(& &1.all_posts())
|> Enum.sort_by(& &1.date, {:desc, Date})
end
@doc "Returns all unique tags across all blogs."
def all_tags do
blogs()
|> Enum.flat_map(& &1.all_tags())
|> Enum.uniq()
|> Enum.sort()
end
@doc "Returns a map of %{blog_id => blog_module} for all registered blogs."
def blogs_map do
Map.new(blogs(), fn mod -> {mod.blog_id(), mod} end)
end
end
+194
View File
@@ -0,0 +1,194 @@
defmodule Blogex.Router do
@moduledoc """
Plug router that serves blog pages and feeds.
Serves HTML to browsers (Accept: text/html) and JSON to API clients.
Mount this in your host app's router:
# In your Phoenix router
scope "/blog" do
pipe_through :browser
forward "/engineering", Blogex.Router, blog: MyApp.EngineeringBlog
forward "/releases", Blogex.Router, blog: MyApp.ReleaseNotes
end
Or use the convenience macro:
import Blogex.Router, only: [blogex_routes: 2]
scope "/blog" do
pipe_through :browser
blogex_routes "/engineering", MyApp.EngineeringBlog
blogex_routes "/releases", MyApp.ReleaseNotes
end
## Routes served
* `GET /` — post index (paginated)
* `GET /:slug` — individual post
* `GET /tag/:tag` — posts by tag
* `GET /feed.xml` — RSS feed
* `GET /atom.xml` — Atom feed
"""
use Plug.Router
plug :match
plug :dispatch
get "/feed.xml" do
blog = conn.private[:blogex_blog]
base_url = Blogex.Router.Helpers.base_url(conn)
xml = Blogex.Feed.rss(blog, base_url)
conn
|> put_resp_content_type("application/rss+xml")
|> send_resp(200, xml)
end
get "/atom.xml" do
blog = conn.private[:blogex_blog]
base_url = Blogex.Router.Helpers.base_url(conn)
xml = Blogex.Feed.atom(blog, base_url)
conn
|> put_resp_content_type("application/atom+xml")
|> send_resp(200, xml)
end
get "/tag/:tag" do
blog = conn.private[:blogex_blog]
posts = blog.posts_by_tag(tag)
if wants_html?(conn) do
assigns = %{
blog_title: blog.title(),
tag: tag,
posts: posts,
base_path: blog.base_path()
}
send_html(conn, Blogex.Layout.tag_page(assigns))
else
conn
|> put_resp_content_type("application/json")
|> send_resp(200, Jason.encode!(%{
blog: blog.blog_id(),
tag: tag,
posts: Enum.map(posts, &post_json/1)
}))
end
end
get "/:slug" do
blog = conn.private[:blogex_blog]
case blog.get_post(slug) do
nil ->
conn |> send_resp(404, "Post not found")
post ->
if wants_html?(conn) do
assigns = %{post: post, base_path: blog.base_path()}
send_html(conn, Blogex.Layout.show_page(assigns))
else
conn
|> put_resp_content_type("application/json")
|> send_resp(200, Jason.encode!(post_json(post)))
end
end
end
get "/" do
blog = conn.private[:blogex_blog]
page = (conn.params["page"] || "1") |> String.to_integer()
result = blog.paginate(page)
if wants_html?(conn) do
assigns = %{
blog_title: blog.title(),
blog_description: blog.description(),
posts: result.entries,
base_path: blog.base_path(),
page: result.page,
total_pages: result.total_pages
}
send_html(conn, Blogex.Layout.index_page(assigns))
else
conn
|> put_resp_content_type("application/json")
|> send_resp(200, Jason.encode!(%{
blog: blog.blog_id(),
title: blog.title(),
posts: Enum.map(result.entries, &post_json/1),
page: result.page,
total_pages: result.total_pages,
total_entries: result.total_entries
}))
end
end
match _ do
send_resp(conn, 404, "Not found")
end
@doc false
def init(opts), do: opts
@doc false
def call(conn, opts) do
blog = Keyword.fetch!(opts, :blog)
conn
|> Plug.Conn.put_private(:blogex_blog, blog)
|> super(opts)
end
defp wants_html?(conn) do
case Plug.Conn.get_req_header(conn, "accept") do
[accept | _] -> String.contains?(accept, "text/html")
_ -> false
end
end
defp send_html(conn, rendered) do
html =
rendered
|> Phoenix.HTML.Safe.to_iodata()
|> IO.iodata_to_binary()
conn
|> put_resp_content_type("text/html")
|> send_resp(200, html)
end
defp post_json(post) do
%{
id: post.id,
title: post.title,
author: post.author,
date: Date.to_iso8601(post.date),
description: post.description,
tags: post.tags,
body: post.body
}
end
defmodule Helpers do
@moduledoc false
def base_url(conn) do
scheme = if conn.scheme == :https, do: "https", else: "http"
port_suffix = port_suffix(conn.scheme, conn.port)
"#{scheme}://#{conn.host}#{port_suffix}"
end
defp port_suffix(:http, 80), do: ""
defp port_suffix(:https, 443), do: ""
defp port_suffix(_, port), do: ":#{port}"
end
end
+62
View File
@@ -0,0 +1,62 @@
defmodule Blogex.SEO do
@moduledoc """
SEO helpers for generating meta tags and sitemaps.
"""
@doc """
Returns a map of meta tag attributes for a post.
Useful for setting OpenGraph and Twitter card tags in your layout.
<meta property="og:title" content={@meta.og_title} />
"""
def meta_tags(post, base_url, blog_module) do
url = "#{base_url}#{blog_module.base_path()}/#{post.id}"
%{
title: post.title,
description: post.description,
og_title: post.title,
og_description: post.description,
og_type: "article",
og_url: url,
article_published_time: Date.to_iso8601(post.date),
article_author: post.author,
article_tags: post.tags,
twitter_card: "summary_large_image"
}
end
@doc """
Generates a sitemap.xml string for the given blog modules.
xml = Blogex.SEO.sitemap([MyApp.EngineeringBlog, MyApp.ReleaseNotes], "https://myapp.com")
"""
def sitemap(blog_modules, base_url) when is_list(blog_modules) do
urls =
blog_modules
|> Enum.flat_map(fn mod ->
mod.all_posts()
|> Enum.map(fn post ->
url = "#{base_url}#{mod.base_path()}/#{post.id}"
lastmod = Date.to_iso8601(post.date)
"""
<url>
<loc>#{url}</loc>
<lastmod>#{lastmod}</lastmod>
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
"""
end)
end)
|> Enum.join()
"""
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
#{urls}</urlset>
"""
|> String.trim()
end
end