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