Skip to main content

A small, file-based Markdown blog engine with a built-in admin.

Project description

volumen — file-based Markdown blog engine

volumen is a small, dependency-light blog engine written in Python. It serves your Markdown posts as a JSON API and includes a built-in, server- rendered admin with both a Markdown source editor and a visual editor — so any front-end (Vue, React, Svelte, plain HTML) can consume your content without re-implementing the engine.

It is designed to be:

  • File-based — every post is a plain .md file with TOML frontmatter, safe to commit to Git and edit in your favourite editor.
  • Self-contained — runs as a single Python process (Uvicorn); no Node build step is required to operate the engine or its admin.
  • Multi-user with roles — username + password login; admin (full access, manages users) and author (posts and own account) roles.
  • Multi-site friendly — one instance per blog, configurable per instance.
  • Markdown-first — the visual editor round-trips through the same renderer that powers the public API, so what you store is always Markdown.
  • IPv6-first — the default bind address is :: (IPv6 with automatic IPv4 fallback); bind to ::1 when running behind nginx so the admin port is not exposed to the network.

Stack

Concern Choice
Language Python 3.14+
Web framework FastAPI
App server Uvicorn (via fastapi[standard])
Markdown python-markdown + pymdown-extensions
Frontmatter TOML via stdlib tomllib + tomli-w
Templating Jinja2
Sessions Starlette SessionMiddleware (signed cookies via itsdangerous)
Packaging Wheel + sdist via setuptools; uv for dependency management
Lint / format Ruff
Tests pytest + httpx

Features (target)

  • Markdown posts with TOML frontmatter (title, date, lang, tags, draft, translations, …)
  • Multi-language posts with a translations map
  • Clean JSON API at /api/volumen/:
    • GET /site — site metadata
    • GET /posts — paginated list with lang, tag, q, page, limit filters
    • GET /posts/{slug} — single post (raw Markdown + rendered HTML)
    • GET /tags — tag cloud with counts
    • GET /feed.xml — RSS 2.0 feed
    • GET /feed.json — JSON Feed 1.1
    • GET /sitemap.xml — sitemap
  • Server-rendered admin at /admin/:
    • Username + password login (scrypt-hashed via hashlib.scrypt, verified in constant time with secrets.compare_digest)
    • Settings page: change password, change username, manage users and roles (admin only)
    • Session cookies (HttpOnly, SameSite=Strict) + CSRF tokens
    • List, create, edit, delete posts
    • Markdown source editor and a visual editor with live preview, both storing Markdown
    • "Reload from disk" to pick up out-of-band edits
  • Permissive CORS for the public API, same-origin only for the admin

Status: the public read API (/api/volumen) and the server-rendered admin (/admin, login + CRUD + Markdown editor with live preview) are implemented and tested. See docs/api-reference.md.


Quick start

Production / first install

# 1. Install the volumen CLI (once, on the machine):
uv tool install volumen                       # or: pipx install volumen

# 2. Bootstrap (once, typically as root — generates config, prompts for
#    an admin password, and installs a hardened systemd unit):
sudo volumen init
sudo systemctl status volumen

# 3. Reverse-proxy with nginx and you're done — see docs/deployment.md.

uv tool install (or pipx install) drops a self-contained volumen executable on PATH; volumen init is the operational bootstrap that renders the config, creates the data dirs, generates a session key, and (with --systemd, on by default under root) installs a hardened unit file. See volumen init --help and docs/deployment.md for the full flow.

Development (this checkout)

# 1. Install dependencies (Python 3.14+ and uv required)
just install

# 2. Run
just run

just install runs uv sync (creates .venv/ and installs the locked dependency set from uv.lock). just run launches the volumen console script from the venv with the bundled sample posts.

The admin will live at http://localhost:9090/admin/ and the API at http://localhost:9090/api/volumen/posts. The dev admin password is admin (the hash lives in ./config.toml).


Post format

Each post is a Markdown file with a TOML frontmatter block delimited by +++:

+++
title = "My post title"
slug = "my-post"            # optional, defaults to the filename
date = 2026-01-15           # first-class TOML date
lang = "en"                 # language code
author = "Petr Balvín"      # optional
fediverse_creator = "@petrbalvin@mastodon.social"  # optional, for Mastodon attribution
tags = ["python", "web"]    # optional
draft = false               # optional
excerpt = "Short summary"   # optional, auto-derived from body if missing
all_langs = false           # optional, show this post in every language

[translations]              # optional, maps lang → slug
cs = "muj-prispevek"
+++

# Heading

Body in **Markdown**, rendered by python-markdown.

Posts are organised on disk as either:

  • content_dir/<slug>.md (default language), or
  • content_dir/<lang>/<slug>.md (per-language subdirectory)

The translations table links posts in different languages together so a front-end can offer a language switcher.

Set all_langs = true on a post to show it in every language (useful for posts that have no per-language translations).

Cover caption

A cover_caption frontmatter field renders a short caption next to the cover image — useful for photo credits or a one-line blurb:

cover = "media/cover.webp"
cover_caption = "Photo by Petr Balvín"

Titled images as figures

A Markdown image with a title (![alt](url "caption")) is rendered as a <figure> with a <figcaption> and a small i info icon. Images without a title render exactly as before:

![A sunset over the hills](media/sunset.webp "Sunset from the ridge above the village")

Fediverse attribution

Set fediverse_creator = "@user@instance.tld" on a post to attach a Mastodon handle to it. The value is exposed as fediverse_creator on the post payload (/api/volumen/posts/{slug} and /api/volumen/posts), and as <dc:creator> in the RSS feed and authors[].name in the JSON feed. A front-end consuming the API can drop it straight into <head>:

<meta name="fediverse:creator" content="@petrbalvin@mastodon.social">

A site-wide default can be set in config.toml ([site].fediverse_creator); a per-post value takes precedence.

Why TOML instead of YAML

  • First-class datesdate = 2026-01-15 is a real date, not a guess.
  • Explicit typing — no YAML implicit-coercion footguns (lang = no becoming false).
  • No whitespace pitfalls — indentation is not significant.

The engine parses it with the Python 3.11+ standard library tomllib; no extra TOML dependency is needed at runtime.


Configuration

A single TOML file, auto-generated at /etc/volumen/config.toml when you run volumen init (or, for local development, by volumen serve on first start). See docs/configuration.md for the full schema.

[server]
host = "::"          # IPv6 + IPv4 fallback; use "::1" behind nginx
port = 9090
env = "production"
trust_proxy = true
cookie_secure = true  # required in production behind HTTPS

content_dir = "/var/lib/volumen/posts"
users_file  = "/var/lib/volumen/users.toml"

[site]
title = "My Blog"
description = "A blog powered by volumen."
base_url = "https://example.com"
language = "en"
author = "Anonymous"

[admin]
password_hash = ""   # populated by `volumen init`
session_key = ""     # populated by `volumen init`
session_ttl = 86400
min_password_length = 10
cookie_secure = false   # set to true in production behind HTTPS

volumen init writes the bootstrap admin hash and a fresh session key for you. To rotate the admin password later:

sudo volumen hash-password   # returns a scrypt$… string; paste into [admin].password_hash

Public API

The API is at /api/volumen/. CORS is open (*) for read endpoints; admin routes are same-origin only.

GET /api/volumen/posts

Name Type Default Description
lang string Filter by language
tag string Filter by tag
q string Search in title/body
page int 1 Page number
limit int 20 Posts per page
{
  "page": 1,
  "page_size": 20,
  "total": 42,
  "has_next": true,
  "has_prev": false,
  "posts": [
    {
      "slug": "welcome",
      "title": "Welcome to volumen",
      "excerpt": "A short introduction…",
      "date": "2026-01-15",
      "lang": "en",
      "tags": ["intro", "python"],
      "author": "Petr Balvín",
      "translations": { "cs": "vitame-vas" },
      "url": "/api/volumen/posts/welcome"
    }
  ]
}

GET /api/volumen/posts/{slug}?lang=en

Returns the full post including the raw Markdown body and the rendered HTML.

{
  "slug": "welcome",
  "title": "Welcome to volumen",
  "date": "2026-01-15",
  "lang": "en",
  "tags": ["intro", "python"],
  "excerpt": "…",
  "body": "# Welcome to **volumen**\n\n…",
  "html": "<h1>Welcome to <strong>volumen</strong></h1>…",
  "translations": { "cs": "vitame-vas" }
}

Development

just              # list recipes
just install      # uv sync (creates .venv/ from uv.lock)
just run          # run the dev server (volumen serve)
just dev          # run against ./posts and ./config.toml on :9090
just build        # ruff check + ruff format --check (zero warnings)
just test         # uv run pytest
just fmt          # ruff format + ruff check --fix
just uninstall    # remove build artifacts (dist/, __pycache__, .pytest_cache, .ruff_cache)

The console script lives at src/volumen/cli.py and is registered as volumen via the [project.scripts] entry in pyproject.toml; uv run shims it onto PATH inside .venv/. In production the CLI is installed globally via uv tool install volumen (or pipx install volumen) and the operational bootstrap is done with sudo volumen init — see docs/deployment.md.


Public site integration

volumen is headless for the front-end: it does not render your public site, it only renders its own admin. The public website (e.g. petrbalvin.org, a Vue 3 + Vite + Tailwind SPA) consumes the JSON API. If you proxy /api/volumen/* through nginx on the same origin, no front-end configuration is required.


Documentation

Contributing

See CONTRIBUTING.md for development setup, code style, commit conventions, the pull request flow, and how releases are cut.

License

MIT — see LICENSE. Copyright © 2026 Petr Balvín

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

volumen-0.4.1.tar.gz (77.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

volumen-0.4.1-py3-none-any.whl (57.8 kB view details)

Uploaded Python 3

File details

Details for the file volumen-0.4.1.tar.gz.

File metadata

  • Download URL: volumen-0.4.1.tar.gz
  • Upload date:
  • Size: 77.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.32 {"installer":{"name":"uv","version":"0.11.32","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Fedora Linux","version":"44","id":"","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for volumen-0.4.1.tar.gz
Algorithm Hash digest
SHA256 ab2da09f5d01c3c5fb9623416738ffb052be6e256ef249d71ba86b1fa8ed0da7
MD5 53e34531d33ab9222d35087a7bb5947b
BLAKE2b-256 e237e12a1bfd800c9613a11496a4274fa774049c5aaa23cbf89deeade61e12b1

See more details on using hashes here.

File details

Details for the file volumen-0.4.1-py3-none-any.whl.

File metadata

  • Download URL: volumen-0.4.1-py3-none-any.whl
  • Upload date:
  • Size: 57.8 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.32 {"installer":{"name":"uv","version":"0.11.32","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Fedora Linux","version":"44","id":"","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for volumen-0.4.1-py3-none-any.whl
Algorithm Hash digest
SHA256 6652d0835bbb6215dd46fa115de51741b4dc8689678957b1fd1b33bb4d3b4f27
MD5 3f1b4c737d415ce82f004176c5f9074b
BLAKE2b-256 61cecd2afba8c3338fe5f757e65ef591b5ec98f4f4014998ef0e4bc742f93273

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page