StaticBlog
A reusable static blog engine for Elixir. Generates a complete website from markdown files with syntax highlighting, RSS feed, sitemap, SEO structured data, and a Daring Fireball-inspired default template. Includes a Micropub/XML-RPC editing server for MarsEdit integration and a publisher that syncs the built site to Cloudflare R2.
Each blog is its own Mix project that depends on :static_blog as a library. Content lives in priv/posts/, templates are swappable via a behaviour, and all configuration is under the :static_blog application key.
Quick start
Generate a new Mix project and add the dependency:
# mix.exs
def deps do
[
{:static_blog, "~> 0.1"},
{:nimble_publisher, "~> 1.1"},
{:makeup_elixir, "~> 0.16"},
{:makeup_erlang, "~> 0.1"}
]
end
Configure your site in config/config.exs:
import Config
config :static_blog, :app, :my_blog
config :static_blog, :site,
title: "My Blog",
tagline: "Writing about things.",
description: "A personal blog about software engineering.",
author: "Your Name",
base_url: "https://blog.example.com",
language: "en"
Create a post at priv/posts/2026-04-11-hello-world.md:
%{
title: "Hello World",
tags: ["intro"],
description: "My first post."
}
---
Welcome to my blog. This is the first post.Build and preview:
mix blog.build
mix blog.serve
# Open http://localhost:4000/Configuration
All configuration lives under the :static_blog application key.
Required
| Key | Type | Purpose |
|---|---|---|
:app | atom |
Consumer OTP application name. Used for Application.app_dir/2 to locate priv/posts/ and priv/static/. |
:site | keyword | Site metadata (see below). |
Site metadata (:site)
| Key | Required | Default | Purpose |
|---|---|---|---|
:title | yes | -- |
Site name, used in masthead, <title>, RSS, and structured data. |
:tagline | yes | -- | Subtitle displayed below the site title. |
:description | yes | -- | Meta description for the home page and RSS channel. |
:author | yes | -- | Default author name. Used when a post has no explicit author. |
:base_url | yes | -- | Production URL (no trailing slash). Used for canonical URLs, Open Graph, and RSS links. |
:language | yes | -- |
ISO 639-1 language code (e.g. "en"). |
:sidebar | no | [] | List of sidebar section maps (see below). |
:colophon_body | no | generic text | Raw HTML string for the colophon page body. |
:robots_disallow | no | [] |
Extra Disallow: paths for robots.txt. |
Sidebar sections
Each sidebar section is a map with a :heading and a list of :links:
config :static_blog, :site,
# ...other keys...
sidebar: [
%{
heading: "Projects",
links: [
%{url: "https://github.com/my-org", label: "My Org", note: "Open source work", rel: "external"},
%{url: "https://example.com", label: "Example"}
]
},
%{
heading: "About",
links: [
%{url: "/colophon/", label: "Colophon"},
%{url: "/feed.xml", label: "RSS feed"}
]
}
]Optional
| Key | Default | Purpose |
|---|---|---|
:template | StaticBlog.Template.Default |
Module implementing StaticBlog.Template behaviour. |
:blog_module | nil |
Module with all_posts/0 for compile-time posts (e.g. your NimblePublisher module). Falls back to StaticBlog.RuntimePosts.all/0. |
:r2 | -- |
R2 publishing config: bucket:, prefix:, region:. Required only for mix blog.publish. |
:r2_env_vars | %{} |
Override env var names for R2 credentials. Keys: :account_id, :access_key_id, :secret_access_key. Defaults to "R2_ACCOUNT_ID", "R2_ACCESS_KEY_ID", "R2_SECRET_ACCESS_KEY". |
:auth_token_env_var | "BLOG_TOKEN" | Environment variable name for the Micropub/XML-RPC bearer token. |
:preview_port | 4000 |
Default TCP port for the static preview server (mix blog.serve and mix blog.server). CLI flags override. |
:api_port | 4010 |
Default TCP port for the Micropub/XML-RPC server (mix blog.server). CLI flags override. |
:micropub_url | <base_url>/micropub |
Override the Micropub endpoint URL advertised in HTML. Set automatically by mix blog.server. |
Mix tasks
| Command | Purpose |
|---|---|
mix blog.build [--output DIR] |
Generate the static site into _site/ (default). |
mix blog.serve [--port 4000] [--dir _site] |
Preview the built site with Erlang's :inets httpd. |
mix blog.server [--api-port 4010] [--preview-port 4000] | Start both the static preview server and the Micropub/XML-RPC editing server. |
mix blog.publish [--output DIR] [--skip-build] | Build and sync to Cloudflare R2. |
Post format
Posts are markdown files in priv/posts/ named YYYY-MM-DD-slug.md. The date and slug are parsed from the filename. Metadata is an Elixir map above a --- separator:
%{
title: "My Post Title",
author: "Author Name",
tags: ["elixir", "release"],
description: "Optional summary for the index page and meta tags.",
status: "published",
published: "2026-04-11T12:00:00Z",
updated: "2026-04-11T14:00:00Z"
}
---
The markdown body starts here.:titleis required.:authoris optional; falls back tosite[:author].:tagsdefaults to[].:statusdefaults to:published. Set to"draft"to exclude from the built site (drafts are still visible in MarsEdit).:publishedand:updatedare optional ISO 8601 timestamps. Default to noon UTC on the filename date.
Template behaviour
Implement StaticBlog.Template to provide custom page rendering:
defmodule MyBlog.Template do
@behaviour StaticBlog.Template
@impl true
def index(posts, site), do: # ... return HTML binary
@impl true
def post(post, site), do: # ...
@impl true
def category(tag, posts, site), do: # ...
@impl true
def colophon(site), do: # ...
@impl true
def not_found(site), do: # ...
endThen configure it:
config :static_blog, :template, MyBlog.Template
The default template (StaticBlog.Template.Default) uses Phoenix components and HEEx, renders a Daring Fireball-inspired default layout with light/dark theme toggle, JSON-LD structured data, and full Open Graph / Twitter Card metadata.
Guides
- Workflow -- day-to-day writing, building, and publishing.
- MarsEdit configuration -- setting up MarsEdit as an editing client.
- Cloudflare R2 setup -- creating a bucket and configuring credentials.
License
Apache 2.0. See LICENSE.md.