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
+5
View File
@@ -0,0 +1,5 @@
[
import_deps: [:phoenix],
plugins: [Phoenix.LiveView.HTMLFormatter],
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]
+6
View File
@@ -0,0 +1,6 @@
/_build/
/deps/
/doc/
*.ez
*.beam
.elixir_ls/
+217
View File
@@ -0,0 +1,217 @@
# Blogex
A multi-blog engine for Phoenix apps, powered by [NimblePublisher](https://github.com/dashbitco/nimble_publisher).
Host an engineering blog **and** release notes (or any number of blogs) from markdown files in your repo. Posts compile into the BEAM at build time — zero database, zero runtime I/O, sub-millisecond reads.
## Features
- **Multi-blog support** — run separate blogs from one app (engineering, release notes, etc.)
- **Markdown + frontmatter** — write posts in your editor, version-control in git
- **Compile-time indexing** — NimblePublisher bakes posts into module attributes
- **Tagging & categorization** — filter posts by tag, per-blog or across all blogs
- **RSS & Atom feeds** — auto-generated feeds per blog
- **SEO helpers** — meta tags, OpenGraph, sitemaps
- **Phoenix components** — unstyled function components you wrap in your layout
- **Mountable router** — forward routes to Blogex, it handles the rest
- **Live reload** — edit a `.md` file, see it instantly in dev
## Repository layout
Blogex is designed to live alongside your Phoenix app in a monorepo:
```
firehose/ ← git root
├── app/ ← your Phoenix SaaS (OTP app: :firehose)
│ ├── lib/
│ │ ├── firehose/
│ │ └── firehose_web/
│ ├── priv/
│ │ └── blog/ ← markdown posts live here
│ │ ├── engineering/
│ │ └── release-notes/
│ └── mix.exs
└── blogex/ ← this library
├── lib/
├── test/
└── mix.exs
```
## Installation
In `app/mix.exs`, add blogex as a path dependency:
```elixir
defp deps do
[
{:blogex, path: "../blogex"},
# Optional: syntax highlighting for code blocks
{:makeup_elixir, ">= 0.0.0"},
{:makeup_erlang, ">= 0.0.0"}
]
end
```
## Setup
### 1. Create your posts directory
Inside your Phoenix app:
```
app/priv/blog/
├── engineering/
│ └── 2026/
│ ├── 03-10-our-new-architecture.md
│ └── 02-15-scaling-postgres.md
└── release-notes/
└── 2026/
└── 03-01-v2-launch.md
```
### 2. Write posts with frontmatter
```markdown
%{
title: "Our New Architecture",
author: "Jane Doe",
tags: ~w(elixir otp architecture),
description: "How we rebuilt our platform on OTP"
}
---
## The problem
Our monolith was getting unwieldy...
## The solution
We broke it into an umbrella of focused OTP apps...
```
The filename encodes the date: `YYYY/MM-DD-slug.md`
### 3. Define blog modules
```elixir
# lib/firehose/blogs/engineering_blog.ex
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
# lib/firehose/blogs/release_notes.ex
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
```
### 4. Configure
```elixir
# config/config.exs
config :blogex,
blogs: [Firehose.EngineeringBlog, Firehose.ReleaseNotes]
```
### 5. Mount routes
```elixir
# lib/firehose_web/router.ex
scope "/blog" do
pipe_through :browser
forward "/engineering", Blogex.Router, blog: Firehose.EngineeringBlog
forward "/releases", Blogex.Router, blog: Firehose.ReleaseNotes
end
```
### 6. Enable live reload (dev only)
```elixir
# config/dev.exs
live_reload: [
patterns: [
...,
~r"priv/blog/*/.*(md)$"
]
]
```
## Usage
### Querying a single blog
```elixir
Firehose.EngineeringBlog.all_posts()
Firehose.EngineeringBlog.recent_posts(5)
Firehose.EngineeringBlog.get_post!("our-new-architecture")
Firehose.EngineeringBlog.posts_by_tag("elixir")
Firehose.EngineeringBlog.all_tags()
Firehose.EngineeringBlog.paginate(1, 10)
```
### Querying across all blogs
```elixir
Blogex.all_posts() # all posts from all blogs, newest first
Blogex.all_tags() # all unique tags across blogs
Blogex.get_blog!(:engineering) # get the blog module
```
### Using Phoenix components
```heex
import Blogex.Components
<.post_index posts={@posts} base_path="/blog/engineering" />
<.post_show post={@post} />
<.tag_list tags={@tags} base_path="/blog/engineering" current_tag={@tag} />
<.pagination page={@page} total_pages={@total_pages} base_path="/blog/engineering" />
```
### Generating feeds
The mounted router serves feeds automatically at `/feed.xml` and `/atom.xml`.
You can also generate them manually:
```elixir
Blogex.Feed.rss(Firehose.EngineeringBlog, "https://firehose.dev")
Blogex.Feed.atom(Firehose.EngineeringBlog, "https://firehose.dev")
```
### SEO
```elixir
# Meta tags for a post
meta = Blogex.SEO.meta_tags(post, "https://firehose.dev", Firehose.EngineeringBlog)
# Sitemap across all blogs
xml = Blogex.SEO.sitemap(Blogex.blogs(), "https://firehose.dev")
```
## Architecture
Blogex follows the **poncho pattern** — it wraps NimblePublisher and presents
a clean API to your Phoenix app. There are no GenServers or processes; all post
data is compiled into BEAM bytecode via module attributes. This means:
- Reads are instant (no I/O, no database)
- Posts update on recompilation (or live reload in dev)
- The host app owns the layout, styling, and routing
- Blogex is a sibling library, not an umbrella child — it has its own `mix.exs` and test suite
- When ready to open-source or publish to Hex, swap `path: "../blogex"` for a version number
## License
MIT
+77
View File
@@ -0,0 +1,77 @@
defmodule FirehoseWeb.BlogLive.Index do
@moduledoc """
Example LiveView for the blog index page.
Copy this into your host app and customize the layout/styling.
Replace `Firehose.EngineeringBlog` with your actual blog module.
"""
use MyAppWeb, :live_view
import Blogex.Components
@impl true
def mount(%{"blog" => blog_id} = _params, _session, socket) do
blog = Blogex.get_blog!(String.to_existing_atom(blog_id))
{:ok,
socket
|> assign(:blog, blog)
|> assign(:page_title, blog.title())}
end
@impl true
def handle_params(params, _uri, socket) do
page = String.to_integer(params["page"] || "1")
tag = params["tag"]
blog = socket.assigns.blog
posts =
if tag do
blog.posts_by_tag(tag)
else
blog.all_posts()
end
per_page = 10
total = length(posts)
total_pages = max(ceil(total / per_page), 1)
entries =
posts
|> Enum.drop((page - 1) * per_page)
|> Enum.take(per_page)
{:noreply,
socket
|> assign(:posts, entries)
|> assign(:tags, blog.all_tags())
|> assign(:current_tag, tag)
|> assign(:page, page)
|> assign(:total_pages, total_pages)
|> assign(:base_path, blog.base_path())}
end
@impl true
def render(assigns) do
~H"""
<div class="max-w-3xl mx-auto py-8 px-4">
<h1 class="text-3xl font-bold mb-2">{@blog.title()}</h1>
<p class="text-gray-600 mb-8">{@blog.description()}</p>
<.tag_list tags={@tags} base_path={@base_path} current_tag={@current_tag} />
<div class="mt-8">
<.post_index posts={@posts} base_path={@base_path} />
</div>
<.pagination page={@page} total_pages={@total_pages} base_path={@base_path} />
<div class="mt-8 text-sm text-gray-500">
<a href={"#{@base_path}/feed.xml"} class="hover:underline">RSS Feed</a>
·
<a href={"#{@base_path}/atom.xml"} class="hover:underline">Atom Feed</a>
</div>
</div>
"""
end
end
+51
View File
@@ -0,0 +1,51 @@
defmodule FirehoseWeb.BlogLive.Show do
@moduledoc """
Example LiveView for showing a single blog post.
Copy this into your host app and customize the layout/styling.
"""
use MyAppWeb, :live_view
import Blogex.Components
@impl true
def mount(%{"blog" => blog_id} = _params, _session, socket) do
blog = Blogex.get_blog!(String.to_existing_atom(blog_id))
{:ok, assign(socket, :blog, blog)}
end
@impl true
def handle_params(%{"slug" => slug}, _uri, socket) do
blog = socket.assigns.blog
post = blog.get_post!(slug)
meta = Blogex.SEO.meta_tags(post, FirehoseWeb.Endpoint.url(), blog)
{:noreply,
socket
|> assign(:post, post)
|> assign(:meta, meta)
|> assign(:page_title, post.title)
|> assign(:base_path, blog.base_path())}
end
@impl true
def render(assigns) do
~H"""
<div class="max-w-3xl mx-auto py-8 px-4">
<nav class="mb-8">
<a href={@base_path} class="text-blue-600 hover:underline">
&larr; Back to {@blog.title()}
</a>
</nav>
<.post_show post={@post} />
<footer class="mt-12 pt-8 border-t border-gray-200">
<h3 class="text-lg font-semibold mb-4">Tags</h3>
<.tag_list tags={@post.tags} base_path={@base_path} />
</footer>
</div>
"""
end
end
+89
View File
@@ -0,0 +1,89 @@
defmodule FirehoseWeb.Router do
@moduledoc """
Example router showing how to integrate Blogex.
You have two options for mounting blogs:
## Option A: Plug Router (JSON API / feeds only)
Uses `Blogex.Router` directly — great for headless / API usage:
scope "/blog" do
pipe_through :browser
forward "/engineering", Blogex.Router, blog: Firehose.EngineeringBlog
forward "/releases", Blogex.Router, blog: Firehose.ReleaseNotes
end
## Option B: LiveView (full UI, recommended)
Uses your own LiveViews with Blogex components — full control over layout:
scope "/blog", MyAppWeb do
pipe_through :browser
live "/engineering", BlogLive.Index, :index,
metadata: %{blog: :engineering}
live "/engineering/:slug", BlogLive.Show, :show,
metadata: %{blog: :engineering}
live "/releases", BlogLive.Index, :index,
metadata: %{blog: :release_notes}
live "/releases/:slug", BlogLive.Show, :show,
metadata: %{blog: :release_notes}
end
# Still mount the Plug router for feeds
scope "/blog" do
forward "/engineering", Blogex.Router, blog: Firehose.EngineeringBlog
forward "/releases", Blogex.Router, blog: Firehose.ReleaseNotes
end
## Option C: Mixed
Use LiveView for the HTML pages but let Blogex.Router handle
feeds and the JSON API. Just make sure the LiveView routes are
defined first so they take priority.
"""
use Phoenix.Router
import Phoenix.LiveView.Router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {FirehoseWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
end
# -- Option B: LiveView routes (recommended) --
scope "/blog", MyAppWeb do
pipe_through :browser
# Engineering blog
live "/engineering", BlogLive.Index, :index
live "/engineering/tag/:tag", BlogLive.Index, :tag
live "/engineering/:slug", BlogLive.Show, :show
# Release notes
live "/releases", BlogLive.Index, :index
live "/releases/tag/:tag", BlogLive.Index, :tag
live "/releases/:slug", BlogLive.Show, :show
end
# Feeds (served by Blogex.Router as Plug)
scope "/blog" do
forward "/engineering", Blogex.Router, blog: Firehose.EngineeringBlog
forward "/releases", Blogex.Router, blog: Firehose.ReleaseNotes
end
# Sitemap
scope "/" do
get "/sitemap.xml", FirehoseWeb.SitemapController, :index
end
end
+14
View File
@@ -0,0 +1,14 @@
defmodule FirehoseWeb.SitemapController do
@moduledoc """
Example controller for serving the blog sitemap.
"""
use MyAppWeb, :controller
def index(conn, _params) do
xml = Blogex.SEO.sitemap(Blogex.blogs(), FirehoseWeb.Endpoint.url())
conn
|> put_resp_content_type("application/xml")
|> send_resp(200, xml)
end
end
+118
View File
@@ -0,0 +1,118 @@
defmodule Blogex do
@moduledoc """
Blogex — a multi-blog engine for Phoenix apps, powered by NimblePublisher.
Blogex lets you host multiple blogs (e.g. engineering blog, release notes)
from markdown files in your repo. Posts are compiled into the BEAM at build
time for instant reads with zero runtime I/O.
## Quick Start
1. Add `blogex` to your dependencies:
```elixir
def deps do
[
{:blogex, "~> 0.1.0"}
]
end
```
2. Create your markdown posts:
```
priv/blog/engineering/2026/03-10-our-new-architecture.md
priv/blog/release-notes/2026/03-01-v2-launch.md
```
Each file has frontmatter + content:
```markdown
%{
title: "Our New Architecture",
author: "Jane Doe",
tags: ~w(elixir architecture),
description: "How we rebuilt our platform"
}
---
Your markdown content here...
```
3. Define blog modules in your app:
```elixir
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 our product",
base_path: "/blog/releases"
end
```
4. Register blogs in your config:
```elixir
config :blogex,
blogs: [Firehose.EngineeringBlog, Firehose.ReleaseNotes]
```
5. Mount routes in your Phoenix router:
```elixir
scope "/blog" do
pipe_through :browser
forward "/engineering", Blogex.Router, blog: Firehose.EngineeringBlog
forward "/releases", Blogex.Router, blog: Firehose.ReleaseNotes
end
```
6. Enable live reloading in `config/dev.exs`:
```elixir
live_reload: [
patterns: [
...,
~r"priv/blog/*/.*(md)$"
]
]
```
## Architecture (Poncho Pattern)
Blogex is designed as a "poncho" — it wraps NimblePublisher and provides
a clean public API while the inner library does the heavy lifting of
markdown parsing and compilation. The host app's supervision tree is used
directly; Blogex adds no processes of its own since all data is compiled
into module attributes at build time.
## Modules
* `Blogex.Blog` — macro to define a blog context
* `Blogex.Post` — post struct
* `Blogex.Registry` — cross-blog queries
* `Blogex.Feed` — RSS/Atom feed generation
* `Blogex.SEO` — meta tags and sitemap generation
* `Blogex.Components` — Phoenix function components
* `Blogex.Router` — mountable Plug router
"""
defdelegate blogs, to: Blogex.Registry
defdelegate get_blog!(blog_id), to: Blogex.Registry
defdelegate get_blog(blog_id), to: Blogex.Registry
defdelegate all_posts, to: Blogex.Registry
defdelegate all_tags, to: Blogex.Registry
end
+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
+58
View File
@@ -0,0 +1,58 @@
defmodule Blogex.MixProject do
use Mix.Project
@version "0.1.0"
@source_url "https://github.com/yourorg/blogex"
def project do
[
app: :blogex,
version: @version,
elixir: "~> 1.15",
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
deps: deps(),
docs: docs(),
package: package(),
description: "A multi-blog engine powered by NimblePublisher for Phoenix apps"
]
end
defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_), do: ["lib"]
def application do
[
extra_applications: [:logger]
]
end
defp deps do
[
{:nimble_publisher, "~> 1.1"},
{:makeup_elixir, ">= 0.0.0"},
{:makeup_erlang, ">= 0.0.0"},
{:phoenix, "~> 1.7"},
{:phoenix_html, "~> 4.0"},
{:phoenix_live_view, "~> 1.0"},
{:jason, "~> 1.4"},
{:plug, "~> 1.15"},
{:ex_doc, "~> 0.34", only: :dev, runtime: false}
]
end
defp docs do
[
main: "readme",
source_url: @source_url,
extras: ["README.md"]
]
end
defp package do
[
licenses: ["MIT"],
links: %{"GitHub" => @source_url}
]
end
end
@@ -0,0 +1,66 @@
%{
title: "How We Test LiveView at Scale",
author: "Carlos Rivera",
tags: ~w(elixir liveview testing),
description: "Our testing strategy for 200+ LiveView modules"
}
---
With over 200 LiveView modules in our codebase, we needed a testing strategy
that was both fast and reliable. Here's what we landed on.
## The three-layer approach
We test LiveViews at three levels:
1. **Unit tests** for the assign logic — pure functions, no rendering
2. **Component tests** for individual function components using `render_component/2`
3. **Integration tests** for full page flows using `live/2`
The key insight is that most bugs live in the assign logic, not in the
templates. By extracting assigns into pure functions, we can test the
interesting bits without mounting a LiveView at all.
```elixir
defmodule MyAppWeb.DashboardLive do
use MyAppWeb, :live_view
# Pure function — easy to test
def compute_metrics(raw_data, date_range) do
raw_data
|> Enum.filter(&in_range?(&1, date_range))
|> Enum.group_by(& &1.category)
|> Enum.map(fn {cat, items} ->
%{category: cat, count: length(items), total: Enum.sum_by(items, & &1.value)}
end)
end
end
# In the test file
test "compute_metrics groups and sums correctly" do
data = [
%{category: "sales", value: 100, date: ~D[2026-03-01]},
%{category: "sales", value: 200, date: ~D[2026-03-02]},
%{category: "support", value: 50, date: ~D[2026-03-01]}
]
result = DashboardLive.compute_metrics(data, {~D[2026-03-01], ~D[2026-03-31]})
assert [
%{category: "sales", count: 2, total: 300},
%{category: "support", count: 1, total: 50}
] = Enum.sort_by(result, & &1.category)
end
```
## Speed matters
Our full test suite runs in under 90 seconds on CI. The secret is
`async: true` everywhere and avoiding database writes in unit tests.
We use `Mox` for external service boundaries and `Ecto.Adapters.SQL.Sandbox`
only for integration tests.
## What we'd do differently
If starting over, we'd adopt property-based testing with `StreamData` earlier.
Several production bugs would have been caught by generating edge-case assigns
rather than hand-writing examples.
@@ -0,0 +1,64 @@
%{
title: "Rebuilding Our Data Pipeline with Broadway",
author: "Jane Doe",
tags: ~w(elixir broadway data-engineering),
description: "How we replaced our Kafka consumer with Broadway for 10x throughput"
}
---
Last quarter we hit a wall with our homegrown Kafka consumer. Message lag was
growing, backpressure was non-existent, and our on-call engineers were losing
sleep. We decided to rebuild on [Broadway](https://github.com/dashbitco/broadway).
## Why Broadway?
Broadway gives us three things our old consumer lacked:
- **Batching** — messages are grouped before hitting the database, cutting our
write volume by 90%
- **Backpressure** — producers only send what consumers can handle
- **Fault tolerance** — failed messages are retried automatically with
configurable strategies
## The migration
We ran both pipelines in parallel for two weeks, comparing output row-by-row.
Once we confirmed parity, we cut over with zero downtime.
```elixir
defmodule MyApp.EventPipeline do
use Broadway
def start_link(_opts) do
Broadway.start_link(__MODULE__,
name: __MODULE__,
producer: [
module: {BroadwayKafka.Producer, [
hosts: [localhost: 9092],
group_id: "my_app_events",
topics: ["events"]
]}
],
processors: [default: [concurrency: 10]],
batchers: [default: [batch_size: 100, batch_timeout: 500]]
)
end
@impl true
def handle_message(_, message, _) do
message
|> Broadway.Message.update_data(&Jason.decode!/1)
end
@impl true
def handle_batch(_, messages, _, _) do
rows = Enum.map(messages, & &1.data)
MyApp.Repo.insert_all("events", rows)
messages
end
end
```
## Results
After the migration, our p99 processing latency dropped from 12s to 180ms and
we haven't had a single page about consumer lag since.
@@ -0,0 +1,33 @@
%{
title: "v2.3.0 — Webhook Improvements & Dark Mode",
author: "Product Team",
tags: ~w(release webhooks ui),
description: "Reliable webhook delivery, dark mode, and improved search"
}
---
Here's what landed in v2.3.0.
## Webhook Reliability
Webhooks now retry with exponential backoff (up to 5 attempts over 24 hours).
You can inspect delivery status and payloads from the new **Webhook Logs**
page in Settings.
Failed deliveries surface in the activity feed so you never miss a dropped
event.
## Dark Mode
The dashboard now respects your system preference. You can also override it
manually from the appearance menu. All charts and graphs adapt automatically.
## Search Improvements
- Full-text search now indexes custom fields
- Search results show highlighted matching fragments
- Filters can be bookmarked and shared via URL
## Deprecations
The `GET /api/v1/users/:id/activity` endpoint is deprecated and will be
removed in v3.0. Use `GET /api/v2/activity?user_id=:id` instead.
@@ -0,0 +1,35 @@
%{
title: "v2.4.0 — Team Dashboards & API Rate Limiting",
author: "Product Team",
tags: ~w(release dashboards api),
description: "New team dashboards, API rate limiting, and 12 bug fixes"
}
---
We're excited to ship v2.4.0 with two major features and a pile of bug fixes.
## Team Dashboards
Every team now gets a shared dashboard showing key metrics at a glance.
Dashboards are fully customizable — drag widgets, set date ranges, and pin
the views that matter most.
## API Rate Limiting
We've introduced tiered rate limits to keep the platform fast for everyone:
| Plan | Requests/min | Burst |
|------------|-------------|-------|
| Free | 60 | 10 |
| Pro | 600 | 50 |
| Enterprise | 6,000 | 200 |
Rate limit headers (`X-RateLimit-Remaining`, `X-RateLimit-Reset`) are now
included in every API response.
## Bug Fixes
- Fixed CSV export failing for reports with more than 10k rows
- Resolved timezone display issue in the activity feed
- Fixed a race condition in webhook delivery retries
- Corrected pagination on the audit log page
- 8 additional minor fixes — see the full changelog
+145
View File
@@ -0,0 +1,145 @@
defmodule Blogex.BlogTest do
use ExUnit.Case
import Blogex.Test.PostBuilder
alias Blogex.Test.FakeBlog
setup do
Blogex.Test.Setup.with_blog()
end
describe "all_posts/0" do
test "excludes drafts", %{blog: blog} do
ids = blog.all_posts() |> Enum.map(& &1.id)
assert "draft-post" not in ids
end
test "returns posts newest first", %{blog: blog} do
dates = blog.all_posts() |> Enum.map(& &1.date)
assert dates == Enum.sort(dates, {:desc, Date})
end
end
describe "recent_posts/1" do
test "returns at most n posts", %{blog: blog} do
assert length(blog.recent_posts(2)) == 2
end
test "returns newest posts", %{blog: blog} do
[first | _] = blog.recent_posts(1)
assert first.id == "newest-post"
end
end
describe "posts_by_tag/1" do
test "returns only posts with the given tag", %{blog: blog} do
posts = blog.posts_by_tag("testing")
assert [%{id: "middle-post"}] = posts
end
test "returns empty list for unknown tag", %{blog: blog} do
assert blog.posts_by_tag("nonexistent") == []
end
test "excludes drafts even if tag matches" do
{:ok, _} = FakeBlog.start([
build(id: "pub", tags: ["elixir"], published: true),
build(id: "draft", tags: ["elixir"], published: false)
])
ids = FakeBlog.posts_by_tag("elixir") |> Enum.map(& &1.id)
assert "pub" in ids
refute "draft" in ids
end
end
describe "all_tags/0" do
test "returns unique sorted tags from published posts", %{blog: blog} do
tags = blog.all_tags()
assert tags == Enum.sort(tags)
assert "elixir" in tags
assert "devops" in tags
end
test "excludes tags only appearing on drafts" do
{:ok, _} = FakeBlog.start([
build(tags: ["visible"], published: true),
build(id: "d", tags: ["hidden"], published: false)
])
refute "hidden" in FakeBlog.all_tags()
end
end
describe "get_post!/1" do
test "returns post by id", %{blog: blog} do
post = blog.get_post!("oldest-post")
assert post.id == "oldest-post"
end
test "raises for unknown id", %{blog: blog} do
assert_raise Blogex.NotFoundError, fn ->
blog.get_post!("nope")
end
end
test "raises for draft post id", %{blog: blog} do
assert_raise Blogex.NotFoundError, fn ->
blog.get_post!("draft-post")
end
end
end
describe "get_post/1" do
test "returns nil for unknown id", %{blog: blog} do
assert blog.get_post("nope") == nil
end
end
describe "paginate/2" do
setup do
posts = build_many(25)
{:ok, _} = FakeBlog.start(posts)
%{blog: FakeBlog}
end
test "returns the correct page size", %{blog: blog} do
result = blog.paginate(1, 10)
assert length(result.entries) == 10
end
test "calculates total pages", %{blog: blog} do
result = blog.paginate(1, 10)
assert result.total_pages == 3
assert result.total_entries == 25
end
test "returns fewer entries on last page", %{blog: blog} do
result = blog.paginate(3, 10)
assert length(result.entries) == 5
end
test "page 2 does not overlap with page 1", %{blog: blog} do
page1_ids = blog.paginate(1, 10).entries |> MapSet.new(& &1.id)
page2_ids = blog.paginate(2, 10).entries |> MapSet.new(& &1.id)
assert MapSet.disjoint?(page1_ids, page2_ids)
end
test "returns empty entries beyond last page", %{blog: blog} do
result = blog.paginate(99, 10)
assert result.entries == []
end
end
end
+133
View File
@@ -0,0 +1,133 @@
defmodule Blogex.FeedTest do
use ExUnit.Case
import Blogex.Test.PostBuilder
alias Blogex.Test.FakeBlog
alias Blogex.Feed
@base_url "https://example.com"
setup do
Blogex.Test.Setup.with_blog(
%{},
blog_id: :eng,
title: "Eng Blog",
description: "Tech articles",
base_path: "/blog/eng"
)
end
describe "rss/3" do
test "produces valid RSS 2.0 XML", %{blog: blog} do
xml = Feed.rss(blog, @base_url)
assert xml =~ ~s(<?xml version="1.0")
assert xml =~ ~s(<rss version="2.0")
assert xml =~ ~s(</rss>)
end
test "includes blog title and description", %{blog: blog} do
xml = Feed.rss(blog, @base_url)
assert xml =~ "<title>Eng Blog</title>"
assert xml =~ "<description>Tech articles</description>"
end
test "includes post entries with correct links", %{blog: blog} do
xml = Feed.rss(blog, @base_url)
assert xml =~ "<link>https://example.com/blog/eng/newest-post</link>"
end
test "wraps post body in CDATA", %{blog: blog} do
xml = Feed.rss(blog, @base_url)
assert xml =~ "<content:encoded><![CDATA["
end
test "includes post tags as categories", %{blog: blog} do
xml = Feed.rss(blog, @base_url)
assert xml =~ "<category>elixir</category>"
end
test "respects limit option" do
{:ok, _} = FakeBlog.start(build_many(10))
xml = Feed.rss(FakeBlog, @base_url, limit: 3)
item_count = xml |> String.split("<item>") |> length() |> Kernel.-(1)
assert item_count == 3
end
test "excludes drafts", %{blog: blog} do
xml = Feed.rss(blog, @base_url)
refute xml =~ "draft-post"
end
test "includes self-referencing atom:link", %{blog: blog} do
xml = Feed.rss(blog, @base_url)
assert xml =~ ~s(href="https://example.com/blog/eng/feed.xml")
assert xml =~ ~s(rel="self")
end
end
describe "atom/3" do
test "produces valid Atom XML", %{blog: blog} do
xml = Feed.atom(blog, @base_url)
assert xml =~ ~s(<feed xmlns="http://www.w3.org/2005/Atom">)
assert xml =~ ~s(</feed>)
end
test "includes post entries", %{blog: blog} do
xml = Feed.atom(blog, @base_url)
assert xml =~ "<entry>"
assert xml =~ ~s(href="https://example.com/blog/eng/newest-post")
end
test "respects limit option" do
{:ok, _} = FakeBlog.start(build_many(10))
xml = Feed.atom(FakeBlog, @base_url, limit: 2)
entry_count = xml |> String.split("<entry>") |> length() |> Kernel.-(1)
assert entry_count == 2
end
end
describe "XML escaping" do
test "escapes special characters in titles" do
{:ok, _} = FakeBlog.start(
[build(title: "Foo & Bar <Baz>")],
title: "A & B"
)
xml = Feed.rss(FakeBlog, @base_url)
assert xml =~ "Foo &amp; Bar &lt;Baz&gt;"
assert xml =~ "<title>A &amp; B</title>"
end
end
describe "empty blog" do
test "rss produces valid XML with no items" do
{:ok, _} = FakeBlog.start([])
xml = Feed.rss(FakeBlog, @base_url)
assert xml =~ "<channel>"
refute xml =~ "<item>"
end
test "atom produces valid XML with no entries" do
{:ok, _} = FakeBlog.start([])
xml = Feed.atom(FakeBlog, @base_url)
assert xml =~ "<feed"
refute xml =~ "<entry>"
end
end
end
@@ -0,0 +1,15 @@
defmodule Blogex.NotFoundErrorTest do
use ExUnit.Case, async: true
test "has a 404 plug status" do
error = %Blogex.NotFoundError{message: "not found"}
assert error.plug_status == 404
end
test "is raisable with a message" do
assert_raise Blogex.NotFoundError, "gone", fn ->
raise Blogex.NotFoundError, "gone"
end
end
end
+71
View File
@@ -0,0 +1,71 @@
defmodule Blogex.PostTest do
use ExUnit.Case, async: true
alias Blogex.Post
describe "build/3" do
test "extracts date from filename path" do
post = Post.build("anything/2026/03-10-my-slug.md", valid_attrs(), "<p>body</p>")
assert post.date == ~D[2026-03-10]
end
test "extracts slug from filename" do
post = Post.build("anything/2026/01-05-cool-feature.md", valid_attrs(), "<p>body</p>")
assert post.id == "cool-feature"
end
test "preserves slug with multiple hyphens" do
post = Post.build("x/2026/06-01-my-multi-part-slug.md", valid_attrs(), "<p>x</p>")
assert post.id == "my-multi-part-slug"
end
test "merges frontmatter attributes into struct" do
attrs = %{
title: "Custom Title",
author: "Specific Author",
description: "Custom desc",
tags: ~w(alpha beta)
}
post = Post.build("x/2026/01-01-x.md", attrs, "<p>x</p>")
assert post.title == "Custom Title"
assert post.author == "Specific Author"
assert post.tags == ["alpha", "beta"]
end
test "stores rendered HTML body" do
html = "<h1>Hello</h1><p>World</p>"
post = Post.build("x/2026/01-01-x.md", valid_attrs(), html)
assert post.body == html
end
test "defaults published to true" do
post = Post.build("x/2026/01-01-x.md", valid_attrs(), "<p>x</p>")
assert post.published == true
end
test "allows overriding published to false" do
attrs = Map.put(valid_attrs(), :published, false)
post = Post.build("x/2026/01-01-x.md", attrs, "<p>x</p>")
assert post.published == false
end
end
defp valid_attrs do
%{
title: "Title",
author: "Author",
description: "Desc",
tags: ["tag"]
}
end
end
+88
View File
@@ -0,0 +1,88 @@
defmodule Blogex.RegistryTest do
use ExUnit.Case
import Blogex.Test.PostBuilder
alias Blogex.Registry
defmodule AlphaBlog do
def blog_id, do: :alpha
def all_posts, do: [Blogex.Test.PostBuilder.build(id: "a1", date: ~D[2026-03-01], blog: :alpha)]
def all_tags, do: ["elixir"]
end
defmodule BetaBlog do
def blog_id, do: :beta
def all_posts, do: [Blogex.Test.PostBuilder.build(id: "b1", date: ~D[2026-03-15], blog: :beta)]
def all_tags, do: ["devops"]
end
setup do
Application.put_env(:blogex, :blogs, [AlphaBlog, BetaBlog])
on_exit(fn -> Application.delete_env(:blogex, :blogs) end)
end
describe "blogs/0" do
test "returns configured blog modules" do
assert Registry.blogs() == [AlphaBlog, BetaBlog]
end
test "returns empty list when unconfigured" do
Application.delete_env(:blogex, :blogs)
assert Registry.blogs() == []
end
end
describe "get_blog!/1" do
test "returns module by blog_id" do
assert Registry.get_blog!(:alpha) == AlphaBlog
end
test "raises for unknown blog_id" do
assert_raise Blogex.NotFoundError, fn ->
Registry.get_blog!(:nonexistent)
end
end
end
describe "get_blog/1" do
test "returns nil for unknown blog_id" do
assert Registry.get_blog(:nonexistent) == nil
end
end
describe "all_posts/0" do
test "merges posts from all blogs" do
ids = Registry.all_posts() |> Enum.map(& &1.id)
assert "a1" in ids
assert "b1" in ids
end
test "sorts merged posts newest first" do
[first, second] = Registry.all_posts()
assert first.id == "b1"
assert second.id == "a1"
end
end
describe "all_tags/0" do
test "merges and deduplicates tags from all blogs" do
tags = Registry.all_tags()
assert "elixir" in tags
assert "devops" in tags
assert length(tags) == length(Enum.uniq(tags))
end
end
describe "blogs_map/0" do
test "returns map keyed by blog_id" do
map = Registry.blogs_map()
assert map[:alpha] == AlphaBlog
assert map[:beta] == BetaBlog
end
end
end
+123
View File
@@ -0,0 +1,123 @@
defmodule Blogex.RouterTest do
use ExUnit.Case
use Plug.Test
import Blogex.Test.PostBuilder
alias Blogex.Test.FakeBlog
setup do
posts = [
build(id: "first-post", title: "First", tags: ["elixir"], date: ~D[2026-03-10]),
build(id: "second-post", title: "Second", tags: ["otp"], date: ~D[2026-02-01]),
build(id: "draft", published: false, date: ~D[2026-03-12])
]
{:ok, _} = FakeBlog.start(posts,
blog_id: :test,
title: "Test Blog",
description: "Test",
base_path: "/blog/test"
)
:ok
end
defp call(method, path) do
conn(method, path)
|> Blogex.Router.call(Blogex.Router.init(blog: FakeBlog))
end
describe "GET /feed.xml" do
test "returns RSS XML" do
conn = call(:get, "/feed.xml")
assert conn.status == 200
assert get_content_type(conn) =~ "application/rss+xml"
assert conn.resp_body =~ "<rss version=\"2.0\""
end
test "includes published posts" do
conn = call(:get, "/feed.xml")
assert conn.resp_body =~ "first-post"
refute conn.resp_body =~ "draft"
end
end
describe "GET /atom.xml" do
test "returns Atom XML" do
conn = call(:get, "/atom.xml")
assert conn.status == 200
assert get_content_type(conn) =~ "application/atom+xml"
assert conn.resp_body =~ "<feed xmlns="
end
end
describe "GET /:slug" do
test "returns post as JSON" do
conn = call(:get, "/first-post")
assert conn.status == 200
body = Jason.decode!(conn.resp_body)
assert body["id"] == "first-post"
assert body["title"] == "First"
end
test "returns 404 for unknown slug" do
conn = call(:get, "/nonexistent")
assert conn.status == 404
end
test "returns 404 for draft post" do
conn = call(:get, "/draft")
assert conn.status == 404
end
end
describe "GET /tag/:tag" do
test "returns posts matching tag" do
conn = call(:get, "/tag/elixir")
assert conn.status == 200
body = Jason.decode!(conn.resp_body)
assert body["tag"] == "elixir"
assert length(body["posts"]) == 1
assert hd(body["posts"])["id"] == "first-post"
end
test "returns empty list for unknown tag" do
conn = call(:get, "/tag/unknown")
body = Jason.decode!(conn.resp_body)
assert body["posts"] == []
end
end
describe "GET /" do
test "returns paginated post list" do
conn = call(:get, "/")
assert conn.status == 200
body = Jason.decode!(conn.resp_body)
assert is_list(body["posts"])
assert body["total_entries"] == 2
end
test "excludes draft posts from listing" do
conn = call(:get, "/")
body = Jason.decode!(conn.resp_body)
ids = Enum.map(body["posts"], & &1["id"])
refute "draft" in ids
end
end
defp get_content_type(conn) do
conn
|> Plug.Conn.get_resp_header("content-type")
|> List.first("")
end
end
+75
View File
@@ -0,0 +1,75 @@
defmodule Blogex.SEOTest do
use ExUnit.Case, async: true
import Blogex.Test.PostBuilder
alias Blogex.SEO
defmodule StubBlog do
def base_path, do: "/blog/eng"
end
@base_url "https://example.com"
describe "meta_tags/3" do
test "includes post title and description" do
post = build(title: "My Title", description: "My Description")
meta = SEO.meta_tags(post, @base_url, StubBlog)
assert meta.title == "My Title"
assert meta.description == "My Description"
end
test "builds canonical URL from base_url and post id" do
post = build(id: "hello-world")
meta = SEO.meta_tags(post, @base_url, StubBlog)
assert meta.og_url == "https://example.com/blog/eng/hello-world"
end
test "includes OpenGraph article metadata" do
post = build(author: "Alice", date: ~D[2026-06-15], tags: ["a", "b"])
meta = SEO.meta_tags(post, @base_url, StubBlog)
assert meta.og_type == "article"
assert meta.article_author == "Alice"
assert meta.article_published_time == "2026-06-15"
assert meta.article_tags == ["a", "b"]
end
end
describe "sitemap/2" do
defmodule BlogA do
def base_path, do: "/blog/a"
def all_posts, do: [Blogex.Test.PostBuilder.build(id: "post-a", date: ~D[2026-01-01])]
end
defmodule BlogB do
def base_path, do: "/blog/b"
def all_posts, do: [Blogex.Test.PostBuilder.build(id: "post-b", date: ~D[2026-02-01])]
end
test "includes URLs from all blog modules" do
xml = SEO.sitemap([BlogA, BlogB], @base_url)
assert xml =~ "<loc>https://example.com/blog/a/post-a</loc>"
assert xml =~ "<loc>https://example.com/blog/b/post-b</loc>"
end
test "produces valid sitemap XML" do
xml = SEO.sitemap([BlogA], @base_url)
assert xml =~ ~s(<?xml version="1.0")
assert xml =~ ~s(<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">)
assert xml =~ "</urlset>"
end
test "includes lastmod from post date" do
xml = SEO.sitemap([BlogA], @base_url)
assert xml =~ "<lastmod>2026-01-01</lastmod>"
end
end
end
+100
View File
@@ -0,0 +1,100 @@
defmodule Blogex.Test.FakeBlog do
@moduledoc """
A test double that implements the same interface as a `use Blogex.Blog`
module, but backed by an Agent so tests can control the post data.
## Usage in tests
setup do
posts = [PostBuilder.build(id: "hello"), PostBuilder.build(id: "world")]
Blogex.Test.FakeBlog.start(posts, blog_id: :engineering, title: "Eng Blog")
:ok
end
Then pass `Blogex.Test.FakeBlog` anywhere a blog module is expected.
"""
use Agent
@defaults [
blog_id: :test_blog,
title: "Test Blog",
description: "A blog for tests",
base_path: "/blog/test"
]
def start(posts \\ [], opts \\ []) do
opts = Keyword.merge(@defaults, opts)
state = %{
posts: posts,
blog_id: opts[:blog_id],
title: opts[:title],
description: opts[:description],
base_path: opts[:base_path]
}
case Agent.start(fn -> state end, name: __MODULE__) do
{:ok, pid} -> {:ok, pid}
{:error, {:already_started, pid}} ->
Agent.update(__MODULE__, fn _ -> state end)
{:ok, pid}
end
end
def stop, do: Agent.stop(__MODULE__)
defp get(key), do: Agent.get(__MODULE__, &Map.fetch!(&1, key))
def blog_id, do: get(:blog_id)
def title, do: get(:title)
def description, do: get(:description)
def base_path, do: get(:base_path)
def all_posts do
get(:posts)
|> Enum.filter(& &1.published)
|> Enum.sort_by(& &1.date, {:desc, Date})
end
def recent_posts(n \\ 5), do: Enum.take(all_posts(), n)
def all_tags do
all_posts()
|> Enum.flat_map(& &1.tags)
|> Enum.uniq()
|> Enum.sort()
end
def posts_by_tag(tag) do
Enum.filter(all_posts(), fn post -> tag in post.tags end)
end
def get_post!(id) do
Enum.find(all_posts(), &(&1.id == id)) ||
raise Blogex.NotFoundError, "post #{inspect(id)} not found"
end
def get_post(id) do
Enum.find(all_posts(), &(&1.id == id))
end
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
+48
View File
@@ -0,0 +1,48 @@
defmodule Blogex.Test.PostBuilder do
@moduledoc """
Builds `%Blogex.Post{}` structs for tests.
Call `build/0` for a post with sensible defaults, or `build/1`
with a keyword list to override only the fields that matter for
your specific test.
build() # generic post
build(title: "Specific Title") # override one field
build(tags: ~w(elixir otp), blog: :eng) # override several
build(published: false) # draft post
"""
@defaults %{
id: "a-blog-post",
title: "A Blog Post",
author: "Test Author",
body: "<p>Post body.</p>",
description: "A test post",
date: ~D[2026-01-15],
tags: ["general"],
blog: :test_blog,
published: true
}
@doc "Build a post with defaults, merging any overrides."
def build(overrides \\ []) do
attrs = Map.merge(@defaults, Map.new(overrides))
struct!(Blogex.Post, attrs)
end
@doc "Build a list of n posts with sequential dates (newest first)."
def build_many(n, overrides \\ []) do
Enum.map(1..n, fn i ->
build(
Keyword.merge(
[
id: "post-#{i}",
title: "Post #{i}",
date: Date.add(~D[2026-01-01], n - i)
],
overrides
)
)
end)
end
end
+50
View File
@@ -0,0 +1,50 @@
defmodule Blogex.Test.Setup do
@moduledoc """
Reusable setup blocks for blog tests.
"""
import Blogex.Test.PostBuilder
@doc """
Starts a FakeBlog with a standard set of posts.
Returns `%{blog: module, posts: posts}`.
"""
def with_blog(context \\ %{}, opts \\ []) do
posts = Keyword.get(opts, :posts, default_posts())
blog_opts = Keyword.drop(opts, [:posts])
{:ok, _} = Blogex.Test.FakeBlog.start(posts, blog_opts)
Map.merge(context, %{blog: Blogex.Test.FakeBlog, posts: posts})
end
@doc "A small set of posts covering common scenarios."
def default_posts do
[
build(
id: "newest-post",
date: ~D[2026-03-10],
tags: ["elixir", "otp"],
published: true
),
build(
id: "middle-post",
date: ~D[2026-02-15],
tags: ["elixir", "testing"],
published: true
),
build(
id: "oldest-post",
date: ~D[2026-01-05],
tags: ["devops"],
published: true
),
build(
id: "draft-post",
date: ~D[2026-03-12],
tags: ["elixir"],
published: false
)
]
end
end
+1
View File
@@ -0,0 +1 @@
ExUnit.start()