This guide covers the day-to-day workflow of writing, previewing, and publishing blog posts.

Writing a post

Create a markdown file in priv/posts/ with the naming convention YYYY-MM-DD-slug.md:

touch priv/posts/2026-04-11-my-new-post.md

Add frontmatter and content:

%{
  title: "My New Post",
  tags: ["elixir"],
  description: "A short summary for the index page."
}
---
The body of your post goes here. Standard markdown is supported,
including fenced code blocks with syntax highlighting for Elixir
and Erlang.

defmodule Hello do def world, do: :ok end

Frontmatter fields

  • :title (required) -- the post title.
  • :author (optional) -- overrides the site-level author for this post.
  • :tags (optional) -- list of category strings. Each tag gets its own category page.
  • :description (optional) -- summary for the index and meta tags. If omitted, the first paragraph is extracted automatically.
  • :status (optional) -- "published" (default) or "draft". Drafts are excluded from the built site but visible in MarsEdit.
  • :published (optional) -- ISO 8601 datetime. Defaults to noon UTC on the filename date.
  • :updated (optional) -- ISO 8601 datetime. Defaults to the published datetime.

Building

Generate the static site into _site/:

mix blog.build

The output directory is deleted and recreated on every build. You can specify a custom output directory:

mix blog.build --output /tmp/my-site

Previewing locally

Simple preview

For a quick preview of the built site:

mix blog.serve
# Open http://localhost:4000/

This uses Erlang's built-in :inets httpd. Clean URLs (e.g. /posts/my-post/) may not resolve correctly because :inets does not do directory-index rewriting.

Full preview with editing

For a proper preview with directory-index rewriting and MarsEdit support:

export BLOG_TOKEN=$(openssl rand -hex 32)
mix blog.server

This starts two servers:

  • Preview at http://localhost:4000/ -- serves _site/ with clean URL support.
  • API at http://localhost:4010/ -- Micropub and XML-RPC endpoints for editing clients.

The site is automatically rebuilt after every create, update, or delete via the API.

Publishing to Cloudflare R2

Set your R2 credentials as environment variables:

export R2_ACCOUNT_ID=your-account-id
export R2_ACCESS_KEY_ID=your-access-key
export R2_SECRET_ACCESS_KEY=your-secret-key

Then build and publish in one step:

mix blog.publish

The publisher compares local files against the remote bucket using MD5/ETag comparisons. Only changed files are uploaded. A .blog-manifest file in the bucket tracks which keys the blog owns, so deleted local files are removed from the bucket without touching other content.

To publish without rebuilding (useful if you already ran mix blog.build):

mix blog.publish --skip-build

Typical session

A typical writing session looks like this:

# Start the server for live preview and MarsEdit editing
export BLOG_TOKEN=$(openssl rand -hex 32)
mix blog.server

# Write and edit posts in MarsEdit or your text editor.
# The preview at http://localhost:4000/ updates automatically
# after each save through MarsEdit.

# When ready to publish:
export R2_ACCOUNT_ID=... R2_ACCESS_KEY_ID=... R2_SECRET_ACCESS_KEY=...
mix blog.publish

Draft workflow

Set status: "draft" in the frontmatter to keep a post out of the published site:

%{
  title: "Work in Progress",
  status: "draft"
}
---
This post won't appear on the public site.

Drafts are visible in MarsEdit and can be edited normally. Change the status to "published" (or remove the status field entirely) when the post is ready, then rebuild and publish.