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

KeyTypePurpose
:appatomConsumer OTP application name. Used for Application.app_dir/2 to locate priv/posts/ and priv/static/.
:sitekeywordSite metadata (see below).

Site metadata (:site)

KeyRequiredDefaultPurpose
:titleyes--Site name, used in masthead, <title>, RSS, and structured data.
:taglineyes--Subtitle displayed below the site title.
:descriptionyes--Meta description for the home page and RSS channel.
:authoryes--Default author name. Used when a post has no explicit author.
:base_urlyes--Production URL (no trailing slash). Used for canonical URLs, Open Graph, and RSS links.
:languageyes--ISO 639-1 language code (e.g. "en").
:sidebarno[]List of sidebar section maps (see below).
:colophon_bodynogeneric textRaw HTML string for the colophon page body.
:robots_disallowno[]Extra Disallow: paths for robots.txt.

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

KeyDefaultPurpose
:templateStaticBlog.Template.DefaultModule implementing StaticBlog.Template behaviour.
:blog_modulenilModule 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_port4000Default TCP port for the static preview server (mix blog.serve and mix blog.server). CLI flags override.
:api_port4010Default TCP port for the Micropub/XML-RPC server (mix blog.server). CLI flags override.
:micropub_url<base_url>/micropubOverride the Micropub endpoint URL advertised in HTML. Set automatically by mix blog.server.

Mix tasks

CommandPurpose
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.
  • :title is required.
  • :author is optional; falls back to site[:author].
  • :tags defaults to [].
  • :status defaults to :published. Set to "draft" to exclude from the built site (drafts are still visible in MarsEdit).
  • :published and :updated are 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: # ...
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.