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:
@@ -0,0 +1,5 @@
|
||||
[
|
||||
import_deps: [:phoenix],
|
||||
plugins: [Phoenix.LiveView.HTMLFormatter],
|
||||
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
|
||||
]
|
||||
@@ -0,0 +1,6 @@
|
||||
/_build/
|
||||
/deps/
|
||||
/doc/
|
||||
*.ez
|
||||
*.beam
|
||||
.elixir_ls/
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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">
|
||||
← 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
>
|
||||
← 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 →
|
||||
</a>
|
||||
</nav>
|
||||
"""
|
||||
end
|
||||
end
|
||||
@@ -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("&", "&")
|
||||
|> String.replace("<", "<")
|
||||
|> String.replace(">", ">")
|
||||
|> String.replace("\"", """)
|
||||
|> String.replace("'", "'")
|
||||
end
|
||||
end
|
||||
@@ -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}>← 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}>← Back</a></nav>
|
||||
<h1>Posts tagged "{@tag}"</h1>
|
||||
<.post_index posts={@posts} base_path={@base_path} />
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
end
|
||||
end
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 & Bar <Baz>"
|
||||
assert xml =~ "<title>A & 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
ExUnit.start()
|
||||
Reference in New Issue
Block a user