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
+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()