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
:templateStaticBlog.Template.Default Module implementing StaticBlog.Template behaviour.
:blog_modulenil 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_port4000 Default TCP port for the static preview server (mix blog.serve and mix blog.server). CLI flags override.
:api_port4010 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.

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

License

Apache 2.0. See LICENSE.md.