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.2"},
{:nimble_publisher, "~> 2.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.
Markdown
Post bodies are CommonMark, rendered by MDEx. Beyond the CommonMark core, tables, strikethrough, autolinks, task lists, smart punctuation, and footnotes are enabled. Raw HTML is passed through rather than stripped, because posts are authored by the site owner rather than submitted by untrusted visitors.
Fenced code blocks are syntax highlighted with Makeup for any language that has a registered lexer, and are emitted as <pre><code class="makeup elixir">. A block whose language has no lexer is left as <pre><code class="language-NAME">.
Footnotes
A footnote is either a labelled reference with a matching definition, or an inline note written where it is cited:
Elixir processes are not OS threads.[^scheduler]
Erlang has had them since 1986.^[Give or take a year.]
[^scheduler]: They are scheduled by the BEAM across a pool of OS threads,
one scheduler per core by default.
Definitions are collected into a <section class="footnotes"> at the end of the post regardless of where they appear in the source, numbered by order of citation, each with a link back to its reference. See the footnotes guide for the rendered markup, a stylesheet to drop into your site, and the behaviour worth knowing before you start writing.
Configuring markdown
Any MDEx option can be set per-site. Options are deep-merged over the defaults, so only the changes need naming:
config :static_blog, :markdown,
extension: [alerts: true],
parse: [smart: false]
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: # ...
end
Then 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.